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