1# ========================================== 2# Unity Project - A Test Framework for C 3# Copyright (c) 2007 Mike Karlesky, Mark VanderVoord, Greg Williams 4# [Released under MIT License. Please refer to license.txt for details] 5# ========================================== 6 7class UnityTestRunnerGenerator 8 def initialize(options = nil) 9 @options = UnityTestRunnerGenerator.default_options 10 case options 11 when NilClass 12 @options 13 when String 14 @options.merge!(UnityTestRunnerGenerator.grab_config(options)) 15 when Hash 16 # Check if some of these have been specified 17 @options[:has_setup] = !options[:setup_name].nil? 18 @options[:has_teardown] = !options[:teardown_name].nil? 19 @options[:has_suite_setup] = !options[:suite_setup].nil? 20 @options[:has_suite_teardown] = !options[:suite_teardown].nil? 21 @options.merge!(options) 22 else 23 raise 'If you specify arguments, it should be a filename or a hash of options' 24 end 25 require_relative 'type_sanitizer' 26 end 27 28 def self.default_options 29 { 30 includes: [], 31 defines: [], 32 plugins: [], 33 framework: :unity, 34 test_prefix: 'test|spec|should', 35 mock_prefix: 'Mock', 36 mock_suffix: '', 37 setup_name: 'setUp', 38 teardown_name: 'tearDown', 39 test_reset_name: 'resetTest', 40 test_verify_name: 'verifyTest', 41 main_name: 'main', # set to :auto to automatically generate each time 42 main_export_decl: '', 43 cmdline_args: false, 44 omit_begin_end: false, 45 use_param_tests: false, 46 include_extensions: '(?:hpp|hh|H|h)', 47 source_extensions: '(?:cpp|cc|ino|C|c)' 48 } 49 end 50 51 def self.grab_config(config_file) 52 options = default_options 53 unless config_file.nil? || config_file.empty? 54 require 'yaml' 55 yaml_guts = YAML.load_file(config_file) 56 options.merge!(yaml_guts[:unity] || yaml_guts[:cmock]) 57 raise "No :unity or :cmock section found in #{config_file}" unless options 58 end 59 options 60 end 61 62 def run(input_file, output_file, options = nil) 63 @options.merge!(options) unless options.nil? 64 65 # pull required data from source file 66 source = File.read(input_file) 67 source = source.force_encoding('ISO-8859-1').encode('utf-8', replace: nil) 68 tests = find_tests(source) 69 headers = find_includes(source) 70 testfile_includes = (headers[:local] + headers[:system]) 71 used_mocks = find_mocks(testfile_includes) 72 testfile_includes = (testfile_includes - used_mocks) 73 testfile_includes.delete_if { |inc| inc =~ /(unity|cmock)/ } 74 find_setup_and_teardown(source) 75 76 # build runner file 77 generate(input_file, output_file, tests, used_mocks, testfile_includes) 78 79 # determine which files were used to return them 80 all_files_used = [input_file, output_file] 81 all_files_used += testfile_includes.map { |filename| filename + '.c' } unless testfile_includes.empty? 82 all_files_used += @options[:includes] unless @options[:includes].empty? 83 all_files_used += headers[:linkonly] unless headers[:linkonly].empty? 84 all_files_used.uniq 85 end 86 87 def generate(input_file, output_file, tests, used_mocks, testfile_includes) 88 File.open(output_file, 'w') do |output| 89 create_header(output, used_mocks, testfile_includes) 90 create_externs(output, tests, used_mocks) 91 create_mock_management(output, used_mocks) 92 create_setup(output) 93 create_teardown(output) 94 create_suite_setup(output) 95 create_suite_teardown(output) 96 create_reset(output) 97 create_run_test(output) unless tests.empty? 98 create_args_wrappers(output, tests) 99 create_main(output, input_file, tests, used_mocks) 100 end 101 102 return unless @options[:header_file] && !@options[:header_file].empty? 103 104 File.open(@options[:header_file], 'w') do |output| 105 create_h_file(output, @options[:header_file], tests, testfile_includes, used_mocks) 106 end 107 end 108 109 def find_tests(source) 110 tests_and_line_numbers = [] 111 112 # contains characters which will be substituted from within strings, doing 113 # this prevents these characters from interfering with scrubbers 114 # @ is not a valid C character, so there should be no clashes with files genuinely containing these markers 115 substring_subs = { '{' => '@co@', '}' => '@cc@', ';' => '@ss@', '/' => '@fs@' } 116 substring_re = Regexp.union(substring_subs.keys) 117 substring_unsubs = substring_subs.invert # the inverse map will be used to fix the strings afterwords 118 substring_unsubs['@quote@'] = '\\"' 119 substring_unsubs['@apos@'] = '\\\'' 120 substring_unre = Regexp.union(substring_unsubs.keys) 121 source_scrubbed = source.clone 122 source_scrubbed = source_scrubbed.gsub(/\\"/, '@quote@') # hide escaped quotes to allow capture of the full string/char 123 source_scrubbed = source_scrubbed.gsub(/\\'/, '@apos@') # hide escaped apostrophes to allow capture of the full string/char 124 source_scrubbed = source_scrubbed.gsub(/("[^"\n]*")|('[^'\n]*')/) { |s| s.gsub(substring_re, substring_subs) } # temporarily hide problematic characters within strings 125 source_scrubbed = source_scrubbed.gsub(/\/\/(?:.+\/\*|\*(?:$|[^\/])).*$/, '') # remove line comments that comment out the start of blocks 126 source_scrubbed = source_scrubbed.gsub(/\/\*.*?\*\//m, '') # remove block comments 127 source_scrubbed = source_scrubbed.gsub(/\/\/.*$/, '') # remove line comments (all that remain) 128 lines = source_scrubbed.split(/(^\s*\#.*$) | (;|\{|\}) /x) # Treat preprocessor directives as a logical line. Match ;, {, and } as end of lines 129 .map { |line| line.gsub(substring_unre, substring_unsubs) } # unhide the problematic characters previously removed 130 131 lines.each_with_index do |line, _index| 132 # find tests 133 next unless line =~ /^((?:\s*(?:TEST_CASE|TEST_RANGE)\s*\(.*?\)\s*)*)\s*void\s+((?:#{@options[:test_prefix]}).*)\s*\(\s*(.*)\s*\)/m 134 135 arguments = Regexp.last_match(1) 136 name = Regexp.last_match(2) 137 call = Regexp.last_match(3) 138 params = Regexp.last_match(4) 139 args = nil 140 141 if @options[:use_param_tests] && !arguments.empty? 142 args = [] 143 arguments.scan(/\s*TEST_CASE\s*\((.*)\)\s*$/) { |a| args << a[0] } 144 145 arguments.scan(/\s*TEST_RANGE\s*\((.*)\)\s*$/).flatten.each do |range_str| 146 args += range_str.scan(/\[\s*(-?\d+.?\d*),\s*(-?\d+.?\d*),\s*(-?\d+.?\d*)\s*\]/).map do |arg_values_str| 147 arg_values_str.map do |arg_value_str| 148 arg_value_str.include?('.') ? arg_value_str.to_f : arg_value_str.to_i 149 end 150 end.map do |arg_values| 151 (arg_values[0]..arg_values[1]).step(arg_values[2]).to_a 152 end.reduce do |result, arg_range_expanded| 153 result.product(arg_range_expanded) 154 end.map do |arg_combinations| 155 arg_combinations.flatten.join(', ') 156 end 157 end 158 end 159 160 tests_and_line_numbers << { test: name, args: args, call: call, params: params, line_number: 0 } 161 end 162 163 tests_and_line_numbers.uniq! { |v| v[:test] } 164 165 # determine line numbers and create tests to run 166 source_lines = source.split("\n") 167 source_index = 0 168 tests_and_line_numbers.size.times do |i| 169 source_lines[source_index..-1].each_with_index do |line, index| 170 next unless line =~ /\s+#{tests_and_line_numbers[i][:test]}(?:\s|\()/ 171 172 source_index += index 173 tests_and_line_numbers[i][:line_number] = source_index + 1 174 break 175 end 176 end 177 178 tests_and_line_numbers 179 end 180 181 def find_includes(source) 182 # remove comments (block and line, in three steps to ensure correct precedence) 183 source.gsub!(/\/\/(?:.+\/\*|\*(?:$|[^\/])).*$/, '') # remove line comments that comment out the start of blocks 184 source.gsub!(/\/\*.*?\*\//m, '') # remove block comments 185 source.gsub!(/\/\/.*$/, '') # remove line comments (all that remain) 186 187 # parse out includes 188 includes = { 189 local: source.scan(/^\s*#include\s+\"\s*(.+\.#{@options[:include_extensions]})\s*\"/).flatten, 190 system: source.scan(/^\s*#include\s+<\s*(.+)\s*>/).flatten.map { |inc| "<#{inc}>" }, 191 linkonly: source.scan(/^TEST_FILE\(\s*\"\s*(.+\.#{@options[:source_extensions]})\s*\"/).flatten 192 } 193 includes 194 end 195 196 def find_mocks(includes) 197 mock_headers = [] 198 includes.each do |include_path| 199 include_file = File.basename(include_path) 200 mock_headers << include_path if include_file =~ /^#{@options[:mock_prefix]}.*#{@options[:mock_suffix]}\.h$/i 201 end 202 mock_headers 203 end 204 205 def find_setup_and_teardown(source) 206 @options[:has_setup] = source =~ /void\s+#{@options[:setup_name]}\s*\(/ 207 @options[:has_teardown] = source =~ /void\s+#{@options[:teardown_name]}\s*\(/ 208 @options[:has_suite_setup] ||= (source =~ /void\s+suiteSetUp\s*\(/) 209 @options[:has_suite_teardown] ||= (source =~ /int\s+suiteTearDown\s*\(int\s+([a-zA-Z0-9_])+\s*\)/) 210 end 211 212 def create_header(output, mocks, testfile_includes = []) 213 output.puts('/* AUTOGENERATED FILE. DO NOT EDIT. */') 214 output.puts("\n/*=======Automagically Detected Files To Include=====*/") 215 output.puts("#include \"#{@options[:framework]}.h\"") 216 output.puts('#include "cmock.h"') unless mocks.empty? 217 if @options[:defines] && !@options[:defines].empty? 218 @options[:defines].each { |d| output.puts("#ifndef #{d}\n#define #{d}\n#endif /* #{d} */") } 219 end 220 if @options[:header_file] && !@options[:header_file].empty? 221 output.puts("#include \"#{File.basename(@options[:header_file])}\"") 222 else 223 @options[:includes].flatten.uniq.compact.each do |inc| 224 output.puts("#include #{inc.include?('<') ? inc : "\"#{inc}\""}") 225 end 226 testfile_includes.each do |inc| 227 output.puts("#include #{inc.include?('<') ? inc : "\"#{inc}\""}") 228 end 229 end 230 mocks.each do |mock| 231 output.puts("#include \"#{mock}\"") 232 end 233 output.puts('#include "CException.h"') if @options[:plugins].include?(:cexception) 234 235 return unless @options[:enforce_strict_ordering] 236 237 output.puts('') 238 output.puts('int GlobalExpectCount;') 239 output.puts('int GlobalVerifyOrder;') 240 output.puts('char* GlobalOrderError;') 241 end 242 243 def create_externs(output, tests, _mocks) 244 output.puts("\n/*=======External Functions This Runner Calls=====*/") 245 output.puts("extern void #{@options[:setup_name]}(void);") 246 output.puts("extern void #{@options[:teardown_name]}(void);") 247 output.puts("\n#ifdef __cplusplus\nextern \"C\"\n{\n#endif") if @options[:externc] 248 tests.each do |test| 249 output.puts("extern void #{test[:test]}(#{test[:call] || 'void'});") 250 end 251 output.puts("#ifdef __cplusplus\n}\n#endif") if @options[:externc] 252 output.puts('') 253 end 254 255 def create_mock_management(output, mock_headers) 256 output.puts("\n/*=======Mock Management=====*/") 257 output.puts('static void CMock_Init(void)') 258 output.puts('{') 259 260 if @options[:enforce_strict_ordering] 261 output.puts(' GlobalExpectCount = 0;') 262 output.puts(' GlobalVerifyOrder = 0;') 263 output.puts(' GlobalOrderError = NULL;') 264 end 265 266 mocks = mock_headers.map { |mock| File.basename(mock, '.*') } 267 mocks.each do |mock| 268 mock_clean = TypeSanitizer.sanitize_c_identifier(mock) 269 output.puts(" #{mock_clean}_Init();") 270 end 271 output.puts("}\n") 272 273 output.puts('static void CMock_Verify(void)') 274 output.puts('{') 275 mocks.each do |mock| 276 mock_clean = TypeSanitizer.sanitize_c_identifier(mock) 277 output.puts(" #{mock_clean}_Verify();") 278 end 279 output.puts("}\n") 280 281 output.puts('static void CMock_Destroy(void)') 282 output.puts('{') 283 mocks.each do |mock| 284 mock_clean = TypeSanitizer.sanitize_c_identifier(mock) 285 output.puts(" #{mock_clean}_Destroy();") 286 end 287 output.puts("}\n") 288 end 289 290 def create_setup(output) 291 return if @options[:has_setup] 292 293 output.puts("\n/*=======Setup (stub)=====*/") 294 output.puts("void #{@options[:setup_name]}(void) {}") 295 end 296 297 def create_teardown(output) 298 return if @options[:has_teardown] 299 300 output.puts("\n/*=======Teardown (stub)=====*/") 301 output.puts("void #{@options[:teardown_name]}(void) {}") 302 end 303 304 def create_suite_setup(output) 305 return if @options[:suite_setup].nil? 306 307 output.puts("\n/*=======Suite Setup=====*/") 308 output.puts('void suiteSetUp(void)') 309 output.puts('{') 310 output.puts(@options[:suite_setup]) 311 output.puts('}') 312 end 313 314 def create_suite_teardown(output) 315 return if @options[:suite_teardown].nil? 316 317 output.puts("\n/*=======Suite Teardown=====*/") 318 output.puts('int suiteTearDown(int num_failures)') 319 output.puts('{') 320 output.puts(@options[:suite_teardown]) 321 output.puts('}') 322 end 323 324 def create_reset(output) 325 output.puts("\n/*=======Test Reset Options=====*/") 326 output.puts("void #{@options[:test_reset_name]}(void);") 327 output.puts("void #{@options[:test_reset_name]}(void)") 328 output.puts('{') 329 output.puts(" #{@options[:teardown_name]}();") 330 output.puts(' CMock_Verify();') 331 output.puts(' CMock_Destroy();') 332 output.puts(' CMock_Init();') 333 output.puts(" #{@options[:setup_name]}();") 334 output.puts('}') 335 output.puts("void #{@options[:test_verify_name]}(void);") 336 output.puts("void #{@options[:test_verify_name]}(void)") 337 output.puts('{') 338 output.puts(' CMock_Verify();') 339 output.puts('}') 340 end 341 342 def create_run_test(output) 343 require 'erb' 344 template = ERB.new(File.read(File.join(__dir__, 'run_test.erb')), nil, '<>') 345 output.puts("\n" + template.result(binding)) 346 end 347 348 def create_args_wrappers(output, tests) 349 return unless @options[:use_param_tests] 350 351 output.puts("\n/*=======Parameterized Test Wrappers=====*/") 352 tests.each do |test| 353 next if test[:args].nil? || test[:args].empty? 354 355 test[:args].each.with_index(1) do |args, idx| 356 output.puts("static void runner_args#{idx}_#{test[:test]}(void)") 357 output.puts('{') 358 output.puts(" #{test[:test]}(#{args});") 359 output.puts("}\n") 360 end 361 end 362 end 363 364 def create_main(output, filename, tests, used_mocks) 365 output.puts("\n/*=======MAIN=====*/") 366 main_name = @options[:main_name].to_sym == :auto ? "main_#{filename.gsub('.c', '')}" : (@options[:main_name]).to_s 367 if @options[:cmdline_args] 368 if main_name != 'main' 369 output.puts("#{@options[:main_export_decl]} int #{main_name}(int argc, char** argv);") 370 end 371 output.puts("#{@options[:main_export_decl]} int #{main_name}(int argc, char** argv)") 372 output.puts('{') 373 output.puts(' int parse_status = UnityParseOptions(argc, argv);') 374 output.puts(' if (parse_status != 0)') 375 output.puts(' {') 376 output.puts(' if (parse_status < 0)') 377 output.puts(' {') 378 output.puts(" UnityPrint(\"#{filename.gsub('.c', '')}.\");") 379 output.puts(' UNITY_PRINT_EOL();') 380 tests.each do |test| 381 if (!@options[:use_param_tests]) || test[:args].nil? || test[:args].empty? 382 output.puts(" UnityPrint(\" #{test[:test]}\");") 383 output.puts(' UNITY_PRINT_EOL();') 384 else 385 test[:args].each do |args| 386 output.puts(" UnityPrint(\" #{test[:test]}(#{args})\");") 387 output.puts(' UNITY_PRINT_EOL();') 388 end 389 end 390 end 391 output.puts(' return 0;') 392 output.puts(' }') 393 output.puts(' return parse_status;') 394 output.puts(' }') 395 else 396 main_return = @options[:omit_begin_end] ? 'void' : 'int' 397 if main_name != 'main' 398 output.puts("#{@options[:main_export_decl]} #{main_return} #{main_name}(void);") 399 end 400 output.puts("#{main_return} #{main_name}(void)") 401 output.puts('{') 402 end 403 output.puts(' suiteSetUp();') if @options[:has_suite_setup] 404 if @options[:omit_begin_end] 405 output.puts(" UnitySetTestFile(\"#{filename.gsub(/\\/, '\\\\\\')}\");") 406 else 407 output.puts(" UnityBegin(\"#{filename.gsub(/\\/, '\\\\\\')}\");") 408 end 409 tests.each do |test| 410 if (!@options[:use_param_tests]) || test[:args].nil? || test[:args].empty? 411 output.puts(" run_test(#{test[:test]}, \"#{test[:test]}\", #{test[:line_number]});") 412 else 413 test[:args].each.with_index(1) do |args, idx| 414 wrapper = "runner_args#{idx}_#{test[:test]}" 415 testname = "#{test[:test]}(#{args})".dump 416 output.puts(" run_test(#{wrapper}, #{testname}, #{test[:line_number]});") 417 end 418 end 419 end 420 output.puts 421 output.puts(' CMock_Guts_MemFreeFinal();') unless used_mocks.empty? 422 if @options[:has_suite_teardown] 423 if @options[:omit_begin_end] 424 output.puts(' (void) suite_teardown(0);') 425 else 426 output.puts(' return suiteTearDown(UnityEnd());') 427 end 428 else 429 output.puts(' return UnityEnd();') unless @options[:omit_begin_end] 430 end 431 output.puts('}') 432 end 433 434 def create_h_file(output, filename, tests, testfile_includes, used_mocks) 435 filename = File.basename(filename).gsub(/[-\/\\\.\,\s]/, '_').upcase 436 output.puts('/* AUTOGENERATED FILE. DO NOT EDIT. */') 437 output.puts("#ifndef _#{filename}") 438 output.puts("#define _#{filename}\n\n") 439 output.puts("#include \"#{@options[:framework]}.h\"") 440 output.puts('#include "cmock.h"') unless used_mocks.empty? 441 @options[:includes].flatten.uniq.compact.each do |inc| 442 output.puts("#include #{inc.include?('<') ? inc : "\"#{inc}\""}") 443 end 444 testfile_includes.each do |inc| 445 output.puts("#include #{inc.include?('<') ? inc : "\"#{inc}\""}") 446 end 447 output.puts "\n" 448 tests.each do |test| 449 if test[:params].nil? || test[:params].empty? 450 output.puts("void #{test[:test]}(void);") 451 else 452 output.puts("void #{test[:test]}(#{test[:params]});") 453 end 454 end 455 output.puts("#endif\n\n") 456 end 457end 458 459if $0 == __FILE__ 460 options = { includes: [] } 461 462 # parse out all the options first (these will all be removed as we go) 463 ARGV.reject! do |arg| 464 case arg 465 when '-cexception' 466 options[:plugins] = [:cexception] 467 true 468 when /\.*\.ya?ml$/ 469 options = UnityTestRunnerGenerator.grab_config(arg) 470 true 471 when /--(\w+)=\"?(.*)\"?/ 472 options[Regexp.last_match(1).to_sym] = Regexp.last_match(2) 473 true 474 when /\.*\.(?:hpp|hh|H|h)$/ 475 options[:includes] << arg 476 true 477 else false 478 end 479 end 480 481 # make sure there is at least one parameter left (the input file) 482 unless ARGV[0] 483 puts ["\nusage: ruby #{__FILE__} (files) (options) input_test_file (output)", 484 "\n input_test_file - this is the C file you want to create a runner for", 485 ' output - this is the name of the runner file to generate', 486 ' defaults to (input_test_file)_Runner', 487 ' files:', 488 ' *.yml / *.yaml - loads configuration from here in :unity or :cmock', 489 ' *.h - header files are added as #includes in runner', 490 ' options:', 491 ' -cexception - include cexception support', 492 ' -externc - add extern "C" for cpp support', 493 ' --setup_name="" - redefine setUp func name to something else', 494 ' --teardown_name="" - redefine tearDown func name to something else', 495 ' --main_name="" - redefine main func name to something else', 496 ' --test_prefix="" - redefine test prefix from default test|spec|should', 497 ' --test_reset_name="" - redefine resetTest func name to something else', 498 ' --test_verify_name="" - redefine verifyTest func name to something else', 499 ' --suite_setup="" - code to execute for setup of entire suite', 500 ' --suite_teardown="" - code to execute for teardown of entire suite', 501 ' --use_param_tests=1 - enable parameterized tests (disabled by default)', 502 ' --omit_begin_end=1 - omit calls to UnityBegin and UnityEnd (disabled by default)', 503 ' --header_file="" - path/name of test header file to generate too'].join("\n") 504 exit 1 505 end 506 507 # create the default test runner name if not specified 508 ARGV[1] = ARGV[0].gsub('.c', '_Runner.c') unless ARGV[1] 509 510 UnityTestRunnerGenerator.new(options).run(ARGV[0], ARGV[1]) 511end 512