syntax_search 0.1.1 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -29,7 +29,7 @@ module SyntaxErrorSearch
29
29
  # Marking a line as invisible also lets the overall program know
30
30
  # that it should not check that area for syntax errors.
31
31
  class CodeLine
32
- attr_reader :line, :index, :indent
32
+ attr_reader :line, :index, :indent, :original_line
33
33
 
34
34
  def initialize(line: , index:)
35
35
  @original_line = line.freeze
@@ -39,6 +39,45 @@ module SyntaxErrorSearch
39
39
  @indent = SpaceCount.indent(line)
40
40
  @status = nil # valid, invalid, unknown
41
41
  @invalid = false
42
+
43
+ @kw_count = 0
44
+ @end_count = 0
45
+ @lex = LexAll.new(source: line)
46
+ @lex.each do |lex|
47
+ next unless lex.type == :on_kw
48
+
49
+ case lex.token
50
+ when 'def', 'case', 'for', 'begin', 'class', 'module', 'if', 'unless', 'while', 'until' , 'do'
51
+ @kw_count += 1
52
+ when 'end'
53
+ @end_count += 1
54
+ end
55
+ end
56
+
57
+ @is_comment = true if @lex.detect {|lex| lex.type != :on_sp}&.type == :on_comment
58
+
59
+ @is_kw = (@kw_count - @end_count) > 0
60
+ @is_end = (@end_count - @kw_count) > 0
61
+ end
62
+
63
+ def <=>(b)
64
+ self.index <=> b.index
65
+ end
66
+
67
+ def is_comment?
68
+ @is_comment
69
+ end
70
+
71
+ def not_comment?
72
+ !is_comment?
73
+ end
74
+
75
+ def is_kw?
76
+ @is_kw
77
+ end
78
+
79
+ def is_end?
80
+ @is_end
42
81
  end
43
82
 
44
83
  def mark_invalid
@@ -72,6 +111,8 @@ module SyntaxErrorSearch
72
111
  index + 1
73
112
  end
74
113
 
114
+ alias :number :line_number
115
+
75
116
  def not_empty?
76
117
  !empty?
77
118
  end
@@ -3,15 +3,16 @@
3
3
  module SyntaxErrorSearch
4
4
  # Searches code for a syntax error
5
5
  #
6
- # The bulk of the heavy lifting is done by the CodeFrontier
6
+ # The bulk of the heavy lifting is done in:
7
7
  #
8
- # The flow looks like this:
8
+ # - CodeFrontier (Holds information for generating blocks and determining if we can stop searching)
9
+ # - ParseBlocksFromLine (Creates blocks into the frontier)
10
+ # - BlockExpand (Expands existing blocks to search more code
9
11
  #
10
12
  # ## Syntax error detection
11
13
  #
12
14
  # When the frontier holds the syntax error, we can stop searching
13
15
  #
14
- #
15
16
  # search = CodeSearch.new(<<~EOM)
16
17
  # def dog
17
18
  # def lol
@@ -23,42 +24,51 @@ module SyntaxErrorSearch
23
24
  # search.invalid_blocks.map(&:to_s) # =>
24
25
  # # => ["def lol\n"]
25
26
  #
26
- #
27
27
  class CodeSearch
28
28
  private; attr_reader :frontier; public
29
29
  public; attr_reader :invalid_blocks, :record_dir, :code_lines
30
30
 
31
- def initialize(string, record_dir: ENV["SYNTAX_SEARCH_RECORD_DIR"])
31
+ def initialize(source, record_dir: ENV["SYNTAX_SEARCH_RECORD_DIR"] || ENV["DEBUG"] ? "tmp" : nil)
32
+ @source = source
32
33
  if record_dir
33
34
  @time = Time.now.strftime('%Y-%m-%d-%H-%M-%s-%N')
34
35
  @record_dir = Pathname(record_dir).join(@time).tap {|p| p.mkpath }
35
36
  @write_count = 0
36
37
  end
37
- @code_lines = string.lines.map.with_index do |line, i|
38
+ @code_lines = source.lines.map.with_index do |line, i|
38
39
  CodeLine.new(line: line, index: i)
39
40
  end
40
41
  @frontier = CodeFrontier.new(code_lines: @code_lines)
41
42
  @invalid_blocks = []
42
43
  @name_tick = Hash.new {|hash, k| hash[k] = 0 }
43
44
  @tick = 0
44
- @scan = IndentScan.new(code_lines: @code_lines)
45
+ @block_expand = BlockExpand.new(code_lines: code_lines)
46
+ @parse_blocks_from_indent_line = ParseBlocksFromIndentLine.new(code_lines: @code_lines)
45
47
  end
46
48
 
49
+ # Used for debugging
47
50
  def record(block:, name: "record")
48
51
  return if !@record_dir
49
52
  @name_tick[name] += 1
50
53
  filename = "#{@write_count += 1}-#{name}-#{@name_tick[name]}.txt"
54
+ if ENV["DEBUG"]
55
+ puts "\n\n==== #{filename} ===="
56
+ puts "\n```#{block.starts_at}:#{block.ends_at}"
57
+ puts "#{block.to_s}"
58
+ puts "```"
59
+ puts " block indent: #{block.current_indent}"
60
+ end
51
61
  @record_dir.join(filename).open(mode: "a") do |f|
52
62
  display = DisplayInvalidBlocks.new(
53
63
  blocks: block,
54
- terminal: false
64
+ terminal: false,
65
+ code_lines: @code_lines,
55
66
  )
56
67
  f.write(display.indent display.code_with_lines)
57
68
  end
58
69
  end
59
70
 
60
- def push_if_invalid(block, name: )
61
- frontier.register(block)
71
+ def push(block, name: )
62
72
  record(block: block, name: name)
63
73
 
64
74
  if block.valid?
@@ -69,33 +79,63 @@ module SyntaxErrorSearch
69
79
  end
70
80
  end
71
81
 
82
+ # Removes the block without putting it back in the frontier
83
+ def sweep(block:, name: )
84
+ record(block: block, name: name)
85
+
86
+ block.lines.each(&:mark_invisible)
87
+ frontier.register_indent_block(block)
88
+ end
89
+
90
+ # Parses the most indented lines into blocks that are marked
91
+ # and added to the frontier
72
92
  def add_invalid_blocks
73
93
  max_indent = frontier.next_indent_line&.indent
74
94
 
75
95
  while (line = frontier.next_indent_line) && (line.indent == max_indent)
76
- neighbors = @scan.neighbors_from_top(frontier.next_indent_line)
77
96
 
78
- @scan.each_neighbor_block(frontier.next_indent_line) do |block|
97
+ @parse_blocks_from_indent_line.each_neighbor_block(frontier.next_indent_line) do |block|
79
98
  record(block: block, name: "add")
80
- if block.valid?
81
- block.lines.each(&:mark_invisible)
82
- end
83
- end
84
99
 
85
- block = CodeBlock.new(lines: neighbors, code_lines: @code_lines)
86
- push_if_invalid(block, name: "add")
100
+ block.mark_invisible if block.valid?
101
+ push(block, name: "add")
102
+ end
87
103
  end
88
104
  end
89
105
 
106
+ # Given an already existing block in the frontier, expand it to see
107
+ # if it contains our invalid syntax
90
108
  def expand_invalid_block
91
109
  block = frontier.pop
92
110
  return unless block
93
111
 
94
- block.expand_until_next_boundry
95
- push_if_invalid(block, name: "expand")
112
+ record(block: block, name: "pop")
113
+
114
+ # block = block.expand_until_next_boundry
115
+ block = @block_expand.call(block)
116
+ push(block, name: "expand")
117
+ end
118
+
119
+ def sweep_heredocs
120
+ HeredocBlockParse.new(
121
+ source: @source,
122
+ code_lines: @code_lines
123
+ ).call.each do |block|
124
+ push(block, name: "heredoc")
125
+ end
126
+ end
127
+
128
+ def sweep_comments
129
+ lines = @code_lines.select(&:is_comment?)
130
+ return if lines.empty?
131
+ block = CodeBlock.new(lines: lines)
132
+ sweep(block: block, name: "comments")
96
133
  end
97
134
 
135
+ # Main search loop
98
136
  def call
137
+ sweep_heredocs
138
+ sweep_comments
99
139
  until frontier.holds_all_syntax_errors?
100
140
  @tick += 1
101
141
 
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SyntaxErrorSearch
4
+ # Outputs code with highlighted lines
5
+ #
6
+ # Whatever is passed to this class will be rendered
7
+ # even if it is "marked invisible" any filtering of
8
+ # output should be done before calling this class.
9
+ #
10
+ #
11
+ # DisplayCodeWithLineNumbers.new(
12
+ # lines: lines,
13
+ # highlight_lines: [lines[2], lines[3]]
14
+ # ).call
15
+ # # =>
16
+ # 1
17
+ # 2 def cat
18
+ # ❯ 3 Dir.chdir
19
+ # ❯ 4 end
20
+ # 5 end
21
+ # 6
22
+ class DisplayCodeWithLineNumbers
23
+ TERMINAL_HIGHLIGHT = "\e[1;3m" # Bold, italics
24
+ TERMINAL_END = "\e[0m"
25
+
26
+ def initialize(lines: , highlight_lines: [], terminal: false)
27
+ @lines = lines.sort
28
+ @terminal = terminal
29
+ @highlight_line_hash = highlight_lines.each_with_object({}) {|line, h| h[line] = true }
30
+ @digit_count = @lines.last&.line_number.to_s.length
31
+ end
32
+
33
+ def call
34
+ @lines.map do |line|
35
+ string = String.new("")
36
+ if @highlight_line_hash[line]
37
+ string << "❯ "
38
+ else
39
+ string << " "
40
+ end
41
+
42
+ number = line.line_number.to_s.rjust(@digit_count)
43
+ string << number.to_s
44
+ if line.empty?
45
+ string << line.original_line
46
+ else
47
+ string << " "
48
+ string << TERMINAL_HIGHLIGHT if @terminal && @highlight_line_hash[line] # Bold, italics
49
+ string << line.original_line
50
+ string << TERMINAL_END if @terminal
51
+ end
52
+ string
53
+ end.join
54
+ end
55
+ end
56
+ end
@@ -1,25 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "capture_code_context"
4
+ require_relative "display_code_with_line_numbers"
5
+
3
6
  module SyntaxErrorSearch
4
7
  # Used for formatting invalid blocks
5
8
  class DisplayInvalidBlocks
6
9
  attr_reader :filename
7
10
 
8
- def initialize(blocks:, io: $stderr, filename: nil, terminal: false)
11
+ def initialize(code_lines: ,blocks:, io: $stderr, filename: nil, terminal: false, invalid_type: :unmatched_end)
9
12
  @terminal = terminal
10
13
  @filename = filename
11
14
  @io = io
12
15
 
13
16
  @blocks = Array(blocks)
14
- @lines = @blocks.map(&:lines).flatten
15
- @code_lines = @blocks.first&.code_lines || []
16
- @digit_count = @code_lines.last&.line_number.to_s.length
17
17
 
18
- @invalid_line_hash = @lines.each_with_object({}) {|line, h| h[line] = true }
18
+ @invalid_lines = @blocks.map(&:lines).flatten
19
+ @code_lines = code_lines
20
+
21
+ @invalid_type = invalid_type
19
22
  end
20
23
 
21
24
  def call
22
- if @blocks.any?
25
+ if @blocks.any? { |b| !b.hidden? }
23
26
  found_invalid_blocks
24
27
  else
25
28
  @io.puts "Syntax OK"
@@ -33,15 +36,28 @@ module SyntaxErrorSearch
33
36
  end
34
37
 
35
38
  private def found_invalid_blocks
36
- @io.puts <<~EOM
39
+ case @invalid_type
40
+ when :missing_end
41
+ @io.puts <<~EOM
37
42
 
38
- SyntaxErrorSearch: A syntax error was detected
43
+ SyntaxSearch: Missing `end` detected
39
44
 
40
- This code has an unmatched `end` this is caused by either
41
- missing a syntax keyword (`def`, `do`, etc.) or inclusion
42
- of an extra `end` line
45
+ This code has a missing `end`. Ensure that all
46
+ syntax keywords (`def`, `do`, etc.) have a matching `end`.
47
+
48
+ EOM
49
+ when :unmatched_end
50
+ @io.puts <<~EOM
51
+
52
+ SyntaxSearch: Unmatched `end` detected
53
+
54
+ This code has an unmatched `end`. Ensure that all `end` lines
55
+ in your code have a matching syntax keyword (`def`, `do`, etc.)
56
+ and that you don't have any extra `end` lines.
57
+
58
+ EOM
59
+ end
43
60
 
44
- EOM
45
61
  @io.puts("file: #{filename}") if filename
46
62
  @io.puts <<~EOM
47
63
  simplified:
@@ -50,50 +66,35 @@ module SyntaxErrorSearch
50
66
  EOM
51
67
  end
52
68
 
53
- def indent(string, with: " ")
69
+ def indent(string, with: " ")
54
70
  string.each_line.map {|l| with + l }.join
55
71
  end
56
72
 
57
73
  def code_block
58
74
  string = String.new("")
59
- string << "```\n"
60
- # string << "#".rjust(@digit_count) + " filename: #{filename}\n\n" if filename
61
- string << code_with_lines
62
- string << "```\n"
75
+ string << code_with_context
63
76
  string
64
77
  end
65
78
 
66
- def terminal_end
67
- "\e[0m"
68
- end
69
-
70
- def terminal_highlight
71
- "\e[1;3m" # Bold, italics
79
+ def code_with_context
80
+ lines = CaptureCodeContext.new(
81
+ blocks: @blocks,
82
+ code_lines: @code_lines
83
+ ).call
84
+
85
+ DisplayCodeWithLineNumbers.new(
86
+ lines: lines,
87
+ terminal: @terminal,
88
+ highlight_lines: @invalid_lines,
89
+ ).call
72
90
  end
73
91
 
74
92
  def code_with_lines
75
- @code_lines.map do |line|
76
- next if line.hidden?
77
-
78
- string = String.new("")
79
- if @invalid_line_hash[line]
80
- string << "❯ "
81
- else
82
- string << " "
83
- end
84
-
85
- number = line.line_number.to_s.rjust(@digit_count)
86
- string << number.to_s
87
- if line.empty?
88
- string << line.to_s
89
- else
90
- string << " "
91
- string << terminal_highlight if @terminal && @invalid_line_hash[line] # Bold, italics
92
- string << line.to_s
93
- string << terminal_end if @terminal
94
- end
95
- string
96
- end.join
93
+ DisplayCodeWithLineNumbers.new(
94
+ lines: @code_lines.select(&:visible?),
95
+ terminal: @terminal,
96
+ highlight_lines: @invalid_lines,
97
+ ).call
97
98
  end
98
99
  end
99
100
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SyntaxErrorSearch
4
+ # Takes in a source, and returns blocks containing each heredoc
5
+ class HeredocBlockParse
6
+ private; attr_reader :code_lines, :lex; public
7
+
8
+ def initialize(source:, code_lines: )
9
+ @code_lines = code_lines
10
+ @lex = LexAll.new(source: source)
11
+ end
12
+
13
+ def call
14
+ blocks = []
15
+ beginning = []
16
+ @lex.each do |lex|
17
+ case lex.type
18
+ when :on_heredoc_beg
19
+ beginning << lex.line
20
+ when :on_heredoc_end
21
+ start_index = beginning.pop - 1
22
+ end_index = lex.line - 1
23
+ blocks << CodeBlock.new(lines: code_lines[start_index..end_index])
24
+ end
25
+ end
26
+
27
+ blocks
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,58 @@
1
+ module SyntaxErrorSearch
2
+ # Ripper.lex is not guaranteed to lex the entire source document
3
+ #
4
+ # lex = LexAll.new(source: source)
5
+ # lex.each do |value|
6
+ # puts value.line
7
+ # end
8
+ class LexAll
9
+ include Enumerable
10
+
11
+ def initialize(source: )
12
+ @lex = Ripper.lex(source)
13
+ lineno = @lex.last&.first&.first + 1
14
+ source_lines = source.lines
15
+ last_lineno = source_lines.count
16
+
17
+ until lineno >= last_lineno
18
+ lines = source_lines[lineno..-1]
19
+
20
+ @lex.concat(Ripper.lex(lines.join, '-', lineno + 1))
21
+ lineno = @lex.last&.first&.first + 1
22
+ end
23
+
24
+ @lex.map! {|(line, _), type, token| LexValue.new(line, _, type, token) }
25
+ end
26
+
27
+ def each
28
+ return @lex.each unless block_given?
29
+ @lex.each do |x|
30
+ yield x
31
+ end
32
+ end
33
+
34
+ def last
35
+ @lex.last
36
+ end
37
+
38
+ # Value object for accessing lex values
39
+ #
40
+ # This lex:
41
+ #
42
+ # [1, 0], :on_ident, "describe", CMDARG
43
+ #
44
+ # Would translate into:
45
+ #
46
+ # lex.line # => 1
47
+ # lex.type # => :on_indent
48
+ # lex.token # => "describe"
49
+ class LexValue
50
+ attr_reader :line, :type, :token
51
+ def initialize(line, _, type, token)
52
+ @line = line
53
+ @type = type
54
+ @token = token
55
+ end
56
+ end
57
+ end
58
+ end