syntax_search 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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