• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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