├── LICENSE.md ├── README.md └── check_nullability.rb /LICENSE.md: -------------------------------------------------------------------------------- 1 | ##The MIT License (MIT) 2 | Copyright (c) 2016 Fabian Ehrentraud 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | This script checks Objective-C headers for nullability annotations. 4 | 5 | Motivation is that in a mixed Objective-C/Swift Xcode project, all headers without nullability annotations are imported as `Implicitly Unwrapped Optionals`. That might lead to runtime crashes in case one forgets to use safe unwraps or checks. 6 | 7 | With this script, all headers that do not contain any nullability annotations can be found. The compiler does the rest: if a header contains at least one nullability annotation, the compiler will output warnings for all other pointers in that file that miss one. Combine it with `Treat Warnings as Errors` for best results. 8 | 9 | Usually one does not want to check all headers, but only the ones imported via the `bridging header`, so this script provides an option to support this. 10 | 11 | For a more detailed problem and solution description, I wrote [this blog post](http://tech.willhaben.at/2016/12/avoiding-implicit.html). 12 | 13 | # Dependencies 14 | 15 | The script depends on `ruby 2.0.0` or higher and does not have any external dependencies. This ruby version is installed by default on OSX El Capitan or macOS Sierra. 16 | 17 | # Running 18 | 19 | To display all the possible options, use the `-h` flag: 20 | 21 | ``` 22 | ./check_nullability.rb -h 23 | ``` 24 | 25 | The script can be run without any options in order to check all `.h` files in the current directory and all subdirectories: 26 | 27 | ``` 28 | ./check_nullability.rb 29 | ``` 30 | 31 | However, it is more useful – or _realistic_ for big existing projects – to only check `#imports` of the `bridging header` (and their recursive imports accordingly): 32 | 33 | ``` 34 | ./check_nullability.rb -s Source/SupportingFiles/MyProject-Bridging-Header.h 35 | ``` 36 | 37 | In order to specify which paths should be searched, include and exclude paths can be provided. Multiple paths can be provided by separating them with `,`: 38 | 39 | ``` 40 | ./check_nullability.rb -s Source/SupportingFiles/MyProject-Bridging-Header.h -i Source -e Source/External,Source/Generated 41 | ``` 42 | 43 | # Integrate with Xcode 44 | 45 | In order to run the script with every build, and thus make sure no imports missing nullability annotations are added to the bridging header in the future, just add a [build phase](https://www.objc.io/issues/6-build-tools/build-process/#build-phases) which executes this script. 46 | 47 | Be sure to insert the build phase **before** the `Compile Sources` build phase. That way the build will be aborted early if nullability is missing, and not recompile the whole project first. 48 | 49 | The script generates an error per header file that misses nullability annotations. All errors will show up in the Xcode issue navigator, where one can conveniently jump to the according file. 50 | 51 | If you want to run the script without aborting the build on error (not recommended), there is the `-w` flag. It will generate warnings instead, and not fail the build. 52 | 53 | As this script only checks each header for having at least ONE nullability annotation, we rely on the support of the compiler. Clang will warn by default about each missing nullability annotation in a header, but only if the header contains at least one nullability annotation. It's a bit weird, but probably Apple wanted to avoid outputting a lot of warnings for legacy projects. Either way, to make that even safer, I'd suggest to also activate `GCC_TREAT_WARNINGS_AS_ERRORS`, so nothing can slip through. 54 | 55 | There might be header files that contain no pointers at all (e.g. headers only containing enum declarations). In order to satisfy the script, one has to add the `NS_ASSUME_NONNULL_BEGIN/END` macros anywhere in the file: 56 | 57 | ``` 58 | NS_ASSUME_NONNULL_BEGIN 59 | NS_ASSUME_NONNULL_END 60 | 61 | // some declarations without pointers 62 | ``` 63 | 64 | # Contributions 65 | 66 | You are invited to make a PR to improve this script. Note that I have limited time to support this project, so please no codestyle-only PRs. 67 | 68 | # License 69 | 70 | MIT License see [LICENSE.md](LICENSE.md). 71 | -------------------------------------------------------------------------------- /check_nullability.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | 4 | # The MIT License (MIT) 5 | # Copyright (c) 2016 Fabian Ehrentraud 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | # The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | 10 | 11 | # This script checks headers for nullability annotations 12 | # Note that each header is actually checked to contain at least ONE nullability annotation, as clang will warn about all missing nullability annotations in a header if at least one annotation in contained 13 | # Main use is for checking the bridging header of a mixed Objective-C/Swift project in order to make using Objective-C code from Swift more safe 14 | # It runs so fast that it can be run with every build in an own build phase 15 | 16 | 17 | require 'set' 18 | require 'optparse' 19 | require 'ostruct' 20 | require 'pathname' 21 | 22 | 23 | options = parse(ARGV) 24 | 25 | # calculating realpaths for the directories instead of all the headers contained saves a lot of time 26 | options.include_paths = options.include_paths.map{|p| p.realpath} 27 | options.exclude_paths = options.exclude_paths.map{|p| p.realpath} 28 | 29 | all_headers = find_all_headers(options.include_paths, options.exclude_paths) 30 | 31 | if all_headers.count == 0 then bail_out('no headers found in search folders', options.warn_only) end 32 | 33 | not_containing_nullability = [] 34 | 35 | if options.start_header 36 | # check only headers that are imported by start_header recursively 37 | recursive_imports = recursive_imports_in_file(options.start_header, all_headers) 38 | 39 | if options.verbose 40 | not_found_imports = recursive_imports.select {|f| make_header_absolute_filename(f, all_headers) == nil} 41 | not_found_imports.each {|i| 42 | puts "import not found/excluded: " + i 43 | } 44 | end 45 | 46 | found_imports_absolute = recursive_imports.map {|f| make_header_absolute_filename(f, all_headers)}.compact 47 | not_containing_nullability = found_imports_absolute.select {|f| not contains_any_nullability?(f)} 48 | else 49 | # check all headers in include_paths 50 | not_containing_nullability = all_headers.select {|f| not contains_any_nullability?(f)} 51 | end 52 | 53 | if not_containing_nullability.empty? 54 | exit 0 55 | else 56 | # format in a way it will show up in xcode issue navigator 57 | warnings = not_containing_nullability.map {|pathname| pathname.to_s + ':0: ' + (options.warn_only ? 'warning' : 'error') + ': missing nullability in file ' + pathname.to_s} 58 | warnings_concatenated = warnings.join("\n") 59 | bail_out(warnings_concatenated, options.warn_only) 60 | end 61 | 62 | 63 | BEGIN { 64 | def parse(args) 65 | options = OpenStruct.new 66 | options.start_header = nil 67 | options.include_paths = [Pathname(".")] 68 | options.exclude_paths = [] 69 | options.warn_only = false 70 | options.verbose = false 71 | 72 | opt_parser = OptionParser.new do |opts| 73 | opts.banner = "Usage: " + File.basename(__FILE__) + " [options]" 74 | 75 | opts.separator "" 76 | opts.separator "Specific options:" 77 | 78 | opts.on("-s", "--start-header HEADER_FILENAME", 79 | "HEADER_FILENAME is the starting point for the search of nullability annotations, from there all #import statements will be followed recursively", 80 | "If this option is not given, then ALL headers in include-paths are searched") do |start_header| 81 | options.start_header = start_header 82 | end 83 | 84 | opts.on("-i x,y,z", "--include-paths PATH1,PATH2,PATH3", Array, 85 | "Comma-separated list of paths to search for headers found in include statements", "If not given, uses the current working directory as include path") do |include_paths| 86 | options.include_paths = include_paths 87 | end 88 | 89 | opts.on("-e x,y,z", "--exclude-paths PATH1,PATH2,PATH3", Array, 90 | "Comma-separated list of paths to exclude from the search for headers") do |exclude_paths| 91 | options.exclude_paths = exclude_paths 92 | end 93 | 94 | opts.on("-w", "--warn-only", "On missing nullability, exit with 0 nonetheless") do |w| 95 | options.warn_only = w 96 | end 97 | 98 | opts.on("-v", "--[no-]verbose", "Run verbosely") do |v| 99 | options.verbose = v 100 | end 101 | 102 | opts.separator "" 103 | opts.separator "Common options:" 104 | 105 | opts.on_tail("-h", "--help", "Show this message") do 106 | puts opts 107 | exit 108 | end 109 | end 110 | 111 | begin 112 | opt_parser.parse! 113 | unless ARGV.length == 0 114 | raise OptionParser::NeedlessArgument.new("too many arguments") 115 | end 116 | 117 | if options.start_header 118 | options.start_header = Pathname(options.start_header) 119 | unless options.start_header.file? 120 | raise OptionParser::InvalidArgument.new("start_header not found at given location") 121 | end 122 | unless options.start_header.extname == ".h" 123 | raise OptionParser::InvalidArgument.new("start_header.h needs to have extension .h, given: " + options.start_header.extname) 124 | end 125 | end 126 | 127 | options.include_paths.each {|p| 128 | unless Pathname(p).directory? 129 | raise OptionParser::InvalidArgument.new("include_path not found at given location: " + p) 130 | end 131 | } 132 | options.include_paths = options.include_paths.map {|p| Pathname(p)} 133 | 134 | options.exclude_paths.each {|p| 135 | unless Pathname(p).directory? 136 | raise OptionParser::InvalidArgument.new("exclude_path not found at given location: " + p) 137 | end 138 | } 139 | options.exclude_paths = options.exclude_paths.map {|p| Pathname(p)} 140 | 141 | rescue OptionParser::InvalidOption, OptionParser::InvalidArgument, OptionParser::MissingArgument 142 | puts $!.to_s 143 | puts options 144 | exit 1 145 | end 146 | 147 | options 148 | end 149 | 150 | # include_pathnames and exclude_pathnames expected to only contain realpaths 151 | def find_all_headers(include_pathnames, exclude_pathnames) 152 | include_pathnames 153 | .flat_map {|p| Pathname.glob(p+"**"+"*.h")} 154 | .uniq 155 | .select {|header_pathname| 156 | header_pathname.file? && exclude_pathnames.none? {|exclude_pathname| 157 | header_pathname.to_path.start_with?(exclude_pathname.to_path) 158 | } 159 | } 160 | end 161 | 162 | # extracts all header filenames that are imported in the given file 163 | def imports_in_file(file_pathname) 164 | file_pathname.readlines.map {|line| line[/.*#import[\s]*("|<)([^">]*)/, 2]}.compact 165 | end 166 | 167 | # returns the filenames as written in the imports 168 | def recursive_imports_in_file(file_pathname, all_header_pathnames, processed_imports_relative = Set.new) 169 | imports_relative = imports_in_file(file_pathname) 170 | unprocessed_imports_relative = imports_relative.select {|f| !processed_imports_relative.include?(f)} 171 | processed_imports_relative += unprocessed_imports_relative 172 | # implicitly removes imports that are not contained in all_header_pathnames 173 | import_pathnames = unprocessed_imports_relative.map {|f| make_header_absolute_filename(f, all_header_pathnames)}.compact 174 | 175 | import_pathnames.each {|p| 176 | processed_imports_relative += recursive_imports_in_file(p, all_header_pathnames, processed_imports_relative) 177 | } 178 | return processed_imports_relative 179 | end 180 | 181 | # all_header_pathnames expected to only contain realpaths 182 | def make_header_absolute_filename(filename, all_header_pathnames) 183 | all_header_pathnames.find {|p| p.to_path.end_with?("/" + filename)} 184 | end 185 | 186 | def contains_any_nullability?(file_pathname) 187 | nullability_cues = ['NS_ASSUME_NONNULL_BEGIN', 'nullable', 'nonnull', '_Nullable', '_Nonnull'] 188 | file_pathname.readlines.any? {|line| nullability_cues.any? {|nullability_cue| line.include?(nullability_cue)} } 189 | end 190 | 191 | def bail_out(message, warn_only) 192 | $stderr.puts message 193 | if warn_only 194 | exit 0 195 | else 196 | exit 1 197 | end 198 | end 199 | } 200 | --------------------------------------------------------------------------------