1#!/usr/bin/env ruby 2# Copyright (c) 2021-2025 Huawei Device Co., Ltd. 3# Licensed under the Apache License, Version 2.0 (the "License"); 4# you may not use this file except in compliance with the License. 5# You may obtain a copy of the License at 6# 7# http://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12# See the License for the specific language governing permissions and 13# limitations under the License. 14 15require 'optparse' 16require 'ostruct' 17require 'logger' 18require 'fileutils' 19require 'open3' 20 21options = OpenStruct.new 22OptionParser.new do |opts| 23 opts.banner = 'Usage: checker.rb [options] TEST_FILE' 24 25 opts.on('--run-prefix=PREFIX', 'Prefix that will be inserted before panda run command') do |v| 26 options.run_prefix = v 27 end 28 opts.on('--source=FILE', 'Path to source file') 29 opts.on('--test-file=FILE', 'Path to test file') do |v| 30 options.test_file = v 31 end 32 opts.on('--panda=PANDA', 'Path to panda') 33 opts.on('--paoc=PAOC', 'Path to paoc') do |v| 34 options.paoc = v 35 end 36 opts.on('--frontend=FRONTEND', 'Path to frontend binary') 37 opts.on('--panda-options=OPTIONS', 'Default options for panda run') do |v| 38 options.panda_options = v 39 end 40 opts.on('--paoc-options=OPTIONS', 'Default options for paoc run') do |v| 41 options.paoc_options = v 42 end 43 opts.on('--frontend-options=OPTIONS', 'Default options for frontend+bco run') do |v| 44 options.frontend_options = v 45 end 46 opts.on('--method=METHOD', 'Method to optimize') 47 opts.on('--command-token=STRING', 'String that is recognized as command start') do |v| 48 options.command_token = v 49 end 50 opts.on('--release', 'Run in release mode. EVENT, INST and other will not be checked') 51 opts.on('-v', '--verbose', 'Verbose logging') 52 opts.on('--arch=ARCHITECTURE', 'Architecture of system where start panda') 53 opts.on("--keep-data", "Do not remove generated data from disk") { |v| options.keep_data = true } 54 opts.on("--with-llvm", "Tells checker that ARK was built with LLVM support") do |v| 55 options.with_llvm = true 56 end 57 opts.on('--checker-filter=STRING', 'Run only checkers with filter-matched name') do |v| 58 options.checker_filter = v 59 end 60 opts.on('--interop', 'Do interop-specific actions') 61 options.interop = true 62end.parse!(into: options) 63 64$LOG_LEVEL = options.verbose ? Logger::DEBUG : Logger::ERROR 65$curr_cmd = nil 66 67def log 68 @log ||= Logger.new($stdout, level: $LOG_LEVEL) 69end 70 71def raise_error(msg) 72 log.error "Test failed: #{$checker_name}" 73 if !$current_method.nil? 74 log.error "Method: \"#{$current_method}\"" 75 end 76 if !$current_pass.nil? 77 log.error $current_pass 78 end 79 log.error msg 80 log.error "Command to reproduce: #{$curr_cmd}" 81 raise msg 82end 83 84def match_str(match) 85 match.is_a?(Regexp) ? "/#{match.source}/" : match 86end 87 88def contains?(str, match) 89 return str =~ match if match.is_a? Regexp 90 91 raise_error "Wrong type for search: #{match.class}" unless match.is_a? String 92 str.include? match 93end 94 95# Provides methods to search lines in a given array 96class SearchScope 97 98 attr_reader :lines 99 attr_reader :current_index 100 101 def initialize(lines, name) 102 @lines = lines 103 @name = name 104 @current_index = 0 105 end 106 107 def find_method_dump(match) 108 @lines = @lines.drop(@current_index) 109 @current_index = 0 110 find(match) 111 @lines = @lines.drop(@current_index - 1) 112 @current_index = 0 113 find(/}$/) 114 @lines = @lines.slice(0, @current_index) 115 @current_index = 0 116 end 117 118 def find_block(match) 119 @lines = @lines.drop(@current_index) 120 @current_index = 0 121 find(match) 122 @lines = @lines.drop(@current_index - 1) 123 @current_index = 0 124 find(/succs:/) 125 @lines = @lines.slice(0, @current_index) 126 @current_index = 0 127 end 128 129 def self.from_file(fname, name) 130 SearchScope.new(File.readlines(fname), name) 131 end 132 133 def find(match) 134 return if match.nil? 135 @current_index = @lines.index { |line| contains?(line, match) } 136 raise_error "#{@name} not found: #{match_str(match)}" if @current_index.nil? 137 @current_index += 1 138 end 139 140 def find_next(match) 141 return if match.nil? 142 143 index = @lines.drop(@current_index).index { |line| contains?(line, match) } 144 145 raise_error "#{@name} not found: #{match_str(match)}" if index.nil? 146 @current_index += index + 1 147 end 148 149 def find_not(match) 150 return if match.nil? 151 152 @lines.each do |line| 153 raise_error "#{@name} should not occur: #{match_str(match)}" if contains?(line, match) 154 end 155 end 156 157 def find_next_not(match) 158 return if match.nil? 159 160 @lines.drop(@current_index).each do |line| 161 raise_error "#{@name} should not occur: #{match_str(match)}" if contains?(line, match) 162 end 163 end 164 165 def to_s 166 "Scope '#{@name}', current=#{@current_index}\n#{@lines.join}" 167 end 168end 169 170class Checker 171 protected 172 attr_writer :name 173 attr_accessor :aot_mode 174 175 public 176 attr_reader :name 177 178 module AotMode 179 PAOC = 1 180 LLVM = 2 181 ALL = 3 182 end 183 184 Code = Struct.new('Code', :source, :filename, :line_no) 185 186 def initialize(options, name, line_no: 0) 187 @name = name 188 @code = Code.new('', options.source, line_no) 189 @cwd = "#{Dir.getwd}/#{name.gsub(/[ -:()<>]/, '_')}" 190 @options = options 191 @args = '' 192 @ir_files = [] 193 @architecture = options.arch 194 @profdata_file = nil 195 @aot_file = '' 196 @aot_mode = nil 197 198 # Events scope for 'events.csv' 199 @events_scope = nil 200 # IR scope for IR dumps files 'ir_dump/*.ir' 201 @ir_scope = nil 202 203 # Disassembly file lines, that were read from 'disasm.txt' 204 @disasm_lines = nil 205 # Currently processing disasm method 206 @disasm_method_scope = nil 207 # Current search scope 208 @disasm_scope = nil 209 210 @run_idx = 0 211 end 212 213 def init_run 214 Dir.mkdir(@cwd) unless File.exists?(@cwd) 215 if @options.interop 216 # module directory need only for interop with ArkJSVM 217 module_dir_path = File.join(@cwd, "module") 218 Dir.mkdir(module_dir_path) unless Dir.exist?(module_dir_path) 219 end 220 clear_data 221 end 222 223 def clone_with(name:, aot_mode:) 224 that = clone 225 that.aot_mode = aot_mode 226 that.name = name 227 that 228 end 229 230 def populate 231 checks = [] 232 if @aot_mode == AotMode::ALL 233 checks << clone_with(aot_mode: AotMode::PAOC, name: "#{@name} [PAOC]") 234 checks << clone_with(aot_mode: AotMode::LLVM, name: "#{@name} [LLVM]") 235 else 236 checks << self 237 end 238 checks 239 end 240 241 def match_filter? 242 @options.checker_filter.nil? or @name.match? @options.checker_filter 243 end 244 245 def append_line(line) 246 @aot_mode = AotMode::LLVM if line.include? "RUN_AOT" 247 @code.source << line + "\n" 248 end 249 250 def RUN(**args) 251 expected_result = 0 252 aborted_sig = 0 253 entry = '_GLOBAL::main' 254 env = '' 255 @args = [] 256 args.each do |name, value| 257 case name 258 when :force_jit 259 next unless value 260 @args << '--compiler-hotness-threshold=0 --no-async-jit=true --compiler-enable-jit=true' 261 when :force_profiling 262 next unless value 263 @args << '--compiler-profiling-threshold=0 --no-async-jit=true --compiler-enable-jit=true' 264 when :pgo_emit_profdata 265 next unless value 266 @profdata_file = "#{@cwd}/#{File.basename(@options.test_file, File.extname(@options.test_file))}.profdata" 267 @args << "--profilesaver-enabled=true --profile-output=#{@profdata_file}" 268 when :options 269 @args << value 270 when :entry 271 entry = value 272 when :result 273 expected_result = value 274 when :abort 275 aborted_sig = value 276 when :env 277 env = value 278 when :aot_file 279 @aot_file = value 280 end 281 end 282 raise ":abort and :result cannot be set at the same time, :abort = #{aborted_sig}, :result = #{expected_result}" if aborted_sig != 0 && expected_result != 0 283 284 clear_data 285 @args = @args.join(' ') 286 aot_arg = @aot_file.empty? ? '' : "--aot-file #{@aot_file}" 287 288 compiler_dump = @options.interop ? "--compiler-dump:folder=./ir_dump" : "--compiler-dump" 289 290 cmd = "#{@options.run_prefix} #{@options.panda} --compiler-queue-type=simple --compiler-ignore-failures=false #{@options.panda_options} \ 291 #{aot_arg} #{@args} --events-output=csv #{compiler_dump} --compiler-disasm-dump:single-file #{@options.test_file} #{entry}" 292 $curr_cmd = "#{env} #{cmd}" 293 log.debug "Panda command: #{$curr_cmd}" 294 295 # See note on exec in RUN_PAOC 296 output, status = Open3.capture2e("#{env} exec #{cmd}", chdir: @cwd.to_s) 297 if aborted_sig != 0 && !status.signaled? 298 puts output 299 log.error "Expected ark to abort with signal #{aborted_sig}, but ark did not signal" 300 raise_error "Test '#{@name}' failed" 301 end 302 if status.signaled? 303 if status.termsig != aborted_sig 304 puts output 305 log.error "ark aborted with signal #{status.termsig}, but expected #{aborted_sig}" 306 raise_error "Test '#{@name}' failed" 307 end 308 elsif status.exitstatus != expected_result 309 puts output 310 log.error "ark returns code #{status.exitstatus}, but expected #{expected_result}" 311 raise_error "Test '#{@name}' failed" 312 end 313 log.debug output 314 File.open("#{@cwd}/console.out", "w") { |file| file.write(output) } 315 316 @events_scope = SearchScope.from_file("#{@cwd}/events.csv", 'Events') 317 end 318 319 def RUN_PAOC(**args) 320 @aot_file = "#{@cwd}/#{File.basename(@options.test_file, File.extname(@options.test_file))}.an" 321 322 inputs = @options.test_file 323 aot_output_option = '--paoc-output' 324 output = @aot_file 325 options = [] 326 env = '' 327 aborted_sig = 0 328 result = 0 329 330 args.each do |name, value| 331 case name 332 when :options 333 options << value 334 when :boot 335 next unless value 336 aot_output_option = '--paoc-boot-output' 337 when :pgo_use_profdata 338 next unless value 339 raise "call RUN with `pgo_emit_profdata: true` (or RUN_PGO_PROF) before :pgo_use_profdata" unless @profdata_file 340 options << "--paoc-use-profile:path=#{@profdata_file},force" 341 options << "--panda-files=#{@options.test_file}" # NOTE (urandon): this is required for compiler's runtime now 342 when :env 343 env = value 344 when :inputs 345 inputs = value 346 when :abort 347 aborted_sig = value 348 when :output 349 output = value 350 when :result 351 result = value 352 end 353 end 354 raise ":abort and :result cannot be set at the same time, :abort = #{aborted_sig}, :result = #{result}" if aborted_sig != 0 && result != 0 355 356 paoc_args = "--paoc-panda-files #{inputs} --events-output=csv --compiler-dump #{options.join(' ')} #{aot_output_option} #{output}" 357 358 clear_data 359 360 cmd = "#{@options.run_prefix} #{@options.paoc} --compiler-ignore-failures=false --compiler-disasm-dump:single-file --compiler-dump #{@options.paoc_options} #{paoc_args}" 361 $curr_cmd = "#{env} #{cmd}" 362 log.debug "Paoc command: #{$curr_cmd}" 363 364 # Using exec to pass signal info to the parent process. 365 # Ruby invokes a process using /bin/sh if the curr_cmd has a metacharacter in it, for example '*', '?', '$'. 366 # If an invoked process signals, then the status.signaled? check below returns different values depending on the shell. 367 # For bash it is true, for dash it is false, because bash propagates a flag, whether the process has signalled or not. 368 # When we use 'exec' we will propagate the signal too 369 output, status = Open3.capture2e("#{env} exec #{cmd}", chdir: @cwd.to_s) 370 if aborted_sig != 0 && !status.signaled? 371 puts output 372 log.error "Expected ark_aot to abort with signal #{aborted_sig}, but ark_aot did not signal" 373 raise_error "Test '#{@name}' failed" 374 end 375 if status.signaled? 376 if status.termsig != aborted_sig 377 puts output 378 log.error "ark_aot aborted with signal #{status.termsig}, but expected #{aborted_sig}" 379 raise_error "Test '#{@name}' failed" 380 end 381 elsif status.exitstatus != result 382 puts output 383 log.error "ark_aot returns code #{status.exitstatus}, but expected #{result}" 384 raise_error "Test '#{@name}' failed" 385 end 386 log.debug output 387 File.open("#{@cwd}/console.out", "w") { |file| file.write(output) } 388 389 @events_scope = SearchScope.from_file("#{@cwd}/events.csv", 'Events') 390 end 391 392 def RUN_AOT(**args) 393 raise 'aot_mode cannot be nil' if @aot_mode.nil? 394 case @aot_mode 395 when AotMode::PAOC 396 RUN_PAOC(**args) 397 when AotMode::LLVM 398 RUN_LLVM(**args) 399 when AotMode::ALL 400 raise 'Checker not populated, run populate()' 401 end 402 end 403 404 def RUN_PGO_PROF(**args) 405 RUN(force_profiling: true, pgo_emit_profdata: true, **args) 406 end 407 408 def RUN_PGO_PAOC(**args) 409 RUN_PAOC(pgo_use_profdata: true, **args) 410 end 411 412 def RUN_BCO(**args) 413 inputs = @options.test_file 414 output = "#{@cwd}/#{File.basename(@options.test_file, '.*')}.abc" 415 @args = '' 416 417 args.each do |name, value| 418 case name 419 when :options 420 @args << value 421 when :inputs 422 inputs = value 423 when :output 424 output = value 425 when :method 426 @args << "--bco-optimizer --method-regex=#{value}:.*" 427 end 428 end 429 430 clear_data 431 $curr_cmd = "#{@options.frontend} --opt-level=2 --dump-assembly --bco-compiler --compiler-dump \ 432 #{@options.frontend_options} #{@args} --output=#{output} #{@options.source}" 433 log.debug "Frontend command: #{$curr_cmd}" 434 435 # See note on exec in RUN_PAOC 436 output, err_output, status = Open3.capture3("exec #{$curr_cmd}", chdir: @cwd.to_s) 437 if status.signaled? 438 if status.termsig != 0 439 puts output 440 log.error "#{@options.frontend} aborted with signal #{status.termsig}, but expected 0" 441 raise_error "Test '#{@name}' failed" 442 end 443 elsif status.exitstatus != 0 444 puts output 445 log.error "#{@options.frontend} returns code #{status.exitstatus}, but expected 0" 446 raise_error "Test '#{@name}' failed" 447 elsif !err_output.empty? 448 log.error "Bytecode optimizer failed, logs:" 449 puts err_output 450 raise_error "Test '#{@name}' failed" 451 end 452 File.open("#{@cwd}/console.out", "w") { |file| file.write(output) } 453 Open3.capture2e("cat #{@cwd}/console.out") 454 FileUtils.touch("#{@cwd}/events.csv") 455 end 456 457 def RUN_LLVM(**args) 458 raise SkipException unless @options.with_llvm 459 460 args[:options] = '' unless args.has_key? :options 461 args[:options] << " --paoc-mode=llvm " 462 RUN_PAOC(**args) 463 end 464 465 def EVENT(match) 466 return if @options.release 467 468 @events_scope.find(match) 469 end 470 471 def EVENT_NEXT(match) 472 return if @options.release 473 474 @events_scope.find_next(match) 475 end 476 477 def EVENT_COUNT(match) 478 return 0 if @options.release 479 480 @events_scope.lines.count { |event| contains?(event, match) } 481 end 482 483 def EVENT_NOT(match) 484 return if @options.release 485 486 @events_scope.find_not(match) 487 end 488 489 def EVENT_NEXT_NOT(match) 490 return if @options.release 491 492 @events_scope.find_next_not(match) 493 end 494 495 def EVENTS_COUNT(match, count) 496 return if @options.release 497 498 res = @events_scope.lines.count { |event| contains?(event, match) } 499 raise_error "Events count missmatch for #{match}, expected: #{count}, real: #{res}" unless res == count 500 end 501 502 def TRUE(condition) 503 return if @options.release 504 505 raise_error "Not true condition: \"#{condition}\"" unless condition 506 end 507 508 class SkipException < StandardError 509 end 510 511 def SKIP_IF(condition) 512 raise SkipException if condition 513 end 514 515 def IR_COUNT(match) 516 return 0 if @options.release 517 @ir_scope.lines.count { |inst| contains?(inst, match) && !contains?(inst, /^Method:/) } 518 end 519 520 def BLOCK_COUNT 521 IR_COUNT('BB ') 522 end 523 524 def INST(match) 525 return if @options.release 526 527 @ir_scope.find(match) 528 end 529 530 def INST_NEXT(match) 531 return if @options.release 532 533 @ir_scope.find_next(match) 534 end 535 536 def INST_NOT(match) 537 return if @options.release 538 @ir_scope.find_not(match) 539 end 540 541 def INST_NEXT_NOT(match) 542 return if @options.release 543 544 @ir_scope.find_next_not(match) 545 end 546 547 def INST_COUNT(match, count) 548 return if @options.release 549 550 real_count = IR_COUNT(match) 551 raise_error "IR_COUNT mismatch for #{match}: expected=#{count}, real=#{real_count}" unless real_count == count 552 end 553 554 def IN_BLOCK(match) 555 return if @options.release 556 557 @ir_scope.find_block(/prop: #{match}/) 558 end 559 560 def LLVM_METHOD(match) 561 return if @options.release 562 563 @ir_scope.find_method_dump(match) 564 end 565 566 def BC_METHOD(match) 567 return if @options.release 568 569 READ_FILE "console.out" 570 @ir_scope.find_method_dump(/^\.function.*#{match.gsub('.', '-')}/) 571 end 572 573 module SearchState 574 NONE = 0 575 SEARCH_BODY = 1 576 SEARCH_END = 2 577 end 578 579 def ASM_METHOD(match) 580 ensure_disasm 581 state = SearchState::NONE 582 start_index = nil 583 end_index = -1 584 @disasm_lines.each_with_index do |line, index| 585 case state 586 when SearchState::NONE 587 if line.start_with?('METHOD_INFO:') && contains?(@disasm_lines[index + 1].split(':', 2)[1].strip, match) 588 state = SearchState::SEARCH_BODY 589 end 590 when SearchState::SEARCH_BODY 591 if line.start_with?('DISASSEMBLY') 592 start_index = index + 1 593 state = SearchState::SEARCH_END 594 end 595 when SearchState::SEARCH_END 596 if line.start_with?('METHOD_INFO:') 597 end_index = index - 1 598 break 599 end 600 end 601 end 602 raise "Method not found: #{match_str(match)}" if start_index.nil? 603 604 @disasm_method_scope = SearchScope.new(@disasm_lines[start_index..end_index], "Method: #{match_str(match)}") 605 @disasm_scope = @disasm_method_scope 606 end 607 608 def ASM_INST(match) 609 ensure_disasm 610 state = SearchState::NONE 611 start_index = nil 612 end_index = -1 613 prefix = nil 614 @disasm_method_scope.lines.each_with_index do |line, index| 615 case state 616 when SearchState::NONE 617 if contains?(line, match) 618 prefix = line.sub(/#.*/, '#').gsub("\n", '') 619 start_index = index + 1 620 state = SearchState::SEARCH_END 621 end 622 when SearchState::SEARCH_END 623 if line.start_with?(prefix) 624 end_index = index - 1 625 break 626 end 627 end 628 end 629 raise "Can not find asm instruction: #{match}" if start_index.nil? 630 631 @disasm_scope = SearchScope.new(@disasm_method_scope.lines[start_index..end_index], "Inst: #{match_str(match)}") 632 end 633 634 def ASM_RESET 635 @disasm_scope = @disasm_method_scope 636 end 637 638 def ASM(**kwargs) 639 ensure_disasm 640 @disasm_scope.find(select_asm(kwargs)) 641 end 642 643 def ASM_NEXT(**kwargs) 644 ensure_disasm 645 @disasm_scope.find_next(select_asm(kwargs)) 646 end 647 648 def ASM_NOT(**kwargs) 649 ensure_disasm 650 @disasm_scope.find_not(select_asm(kwargs)) 651 end 652 653 def ASM_NEXT_NOT(**kwargs) 654 ensure_disasm 655 @disasm_scope.find_next_not(select_asm(kwargs)) 656 end 657 658 def select_asm(kwargs) 659 kwargs[@options.arch.to_sym] 660 end 661 662 def ensure_disasm 663 @disasm_lines ||= File.readlines("#{@cwd}/disasm.txt") 664 end 665 666 def METHOD(method) 667 return if @options.release 668 @ir_files = Dir["#{@cwd}/ir_dump/*#{method.gsub(/::|[<>]|\.|-/, '_')}*.ir"] 669 @ir_files.sort! 670 raise_error "IR dumps not found for method: #{method.gsub(/::|[<>]|\.|-/, '_')}" if @ir_files.empty? 671 $current_method = method 672 @current_file_index = 0 673 end 674 675 def PASS_AFTER(pass) 676 return if @options.release 677 678 $current_pass = "Pass after: #{pass}" 679 @current_file_index = @ir_files.index { |x| File.basename(x).include? pass } 680 raise_error "IR file not found for pass: #{pass}. Possible cause: you forgot to select METHOD first" unless @current_file_index 681 @ir_scope = SearchScope.from_file(@ir_files[@current_file_index], 'IR') 682 end 683 684 def PASS_AFTER_NEXT(pass) 685 return if @options.release 686 687 $current_pass = "Pass after next: #{pass}" 688 index = @ir_files[(@current_file_index + 1)..-1].index { |x| File.basename(x).include? pass } 689 raise_error "IR file not found for pass: #{pass}. Possible cause: you forgot to select METHOD first" unless index 690 @current_file_index += 1 + index 691 @ir_scope = SearchScope.from_file(@ir_files[@current_file_index], 'IR') 692 end 693 694 def PASS_BEFORE(pass) 695 return if @options.release 696 697 $current_pass = "Pass before: #{pass}" 698 @current_file_index = @ir_files.index { |x| File.basename(x).include? pass } 699 raise_error "IR file not found for pass: #{pass}. Possible cause: you forgot to select METHOD first" unless @current_file_index 700 @ir_scope = SearchScope.from_file(@ir_files[@current_file_index - 1], 'IR') 701 end 702 703 def READ_FILE(filename) 704 path = "#{@cwd}/#{filename}" 705 raise_error "File `#{filename}` not found" unless File.file?(path) 706 @ir_scope = SearchScope.from_file(path, 'Plain text') 707 end 708 709 def run 710 unless match_filter? 711 log.info "Filtered-out: \"#{@name}\"" 712 return 713 end 714 715 log.info "Running \"#{@name}\"" 716 init_run 717 $checker_name = @name 718 begin 719 self.instance_eval(@code.source, @code.filename, @code.line_no) 720 rescue SkipException 721 log.info "Skipped: \"#{@name}\"" 722 else 723 log.info "Success: \"#{@name}\"" 724 end 725 clear_data 726 end 727 728 def clear_data 729 $current_method = nil 730 $current_pass = nil 731 if !@options.keep_data 732 FileUtils.rm_rf("#{@cwd}/events.csv") 733 FileUtils.rm_rf("#{@cwd}/disasm.txt") 734 FileUtils.rm_rf("#{@cwd}/console.out") 735 else 736 @run_idx += 1 737 FileUtils.mv "#{@cwd}/events.csv", "#{@cwd}/events-#{@run_idx}.csv", force: true 738 FileUtils.mv "#{@cwd}/disasm.txt", "#{@cwd}/disasm-#{@run_idx}.txt", force: true 739 FileUtils.mv "#{@cwd}/console.out", "#{@cwd}/console-#{@run_idx}.out", force: true 740 end 741 end 742end 743 744def read_checks(options) 745 checks = [] 746 check = nil 747 command_token = /[ ]*#{options.command_token}(.*)/ 748 checker_start = /[ ]*#{options.command_token} CHECKER[ ]*(.*)/ 749 disabled_checker_start = /[ ]*#{options.command_token} DISABLED_CHECKER[ ]*(.*)/ 750 File.readlines(options.source).each_with_index do |line, line_no| 751 if check 752 unless line.start_with? command_token 753 check = nil 754 next 755 end 756 raise "No space between two checkers: '#{line.strip}'" if line.start_with? checker_start 757 check.append_line(command_token.match(line)[1]) unless check == :disabled_check 758 else 759 next unless line.start_with? command_token 760 if line.start_with? checker_start 761 name = command_token.match(line)[1] 762 raise "Checker with name '#{name}'' already exists" if checks.any? { |x| x.name == name } 763 764 check = Checker.new(options, name, line_no: line_no) 765 checks << check 766 else 767 raise "Line '#{line.strip}' does not belong to any checker" unless line.start_with? disabled_checker_start 768 check = :disabled_check 769 next 770 end 771 end 772 end 773 checks 774end 775 776def main(options) 777 read_checks(options).flat_map(&:populate).each(&:run) 778 0 779end 780 781if __FILE__ == $PROGRAM_NAME 782 main(options) 783end 784 785# Somehow ruby resolves `Checker` name to another class in a Testing scope, so make this global 786# variable to refer to it from unit tests. I believe there is more proper way to do it, but I 787# didn't find it at first glance. 788$CheckerForTest = Checker 789