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