/
annotated_source.cr
181 lines (157 loc) · 5.7 KB
/
annotated_source.cr
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
# Parsed representation of code annotated with the `# ^^^ error: Message` style
class Ameba::Spec::AnnotatedSource
ANNOTATION_PATTERN_1 = /\A\s*(# )?(\^+|\^{})( error:)? /
ANNOTATION_PATTERN_2 = " # error: "
ABBREV = "[...]"
getter lines : Array(String)
# Each entry is the line number, annotation prefix, and message.
# The prefix is empty if the annotation is at the end of a code line.
getter annotations : Array({Int32, String, String})
# Separates annotation lines from code lines. Tracks the real
# code line number that each annotation corresponds to.
def self.parse(annotated_code)
lines = [] of String
annotations = [] of {Int32, String, String}
code_lines = annotated_code.split('\n') # must preserve trailing newline
code_lines.each do |code_line|
case
when annotation_match = ANNOTATION_PATTERN_1.match(code_line)
message_index = annotation_match.end
prefix = code_line[0...message_index]
message = code_line[message_index...]
annotations << {lines.size, prefix, message}
when annotation_index = code_line.index(ANNOTATION_PATTERN_2)
lines << code_line[...annotation_index]
message_index = annotation_index + ANNOTATION_PATTERN_2.size
message = code_line[message_index...]
annotations << {lines.size, "", message}
else
lines << code_line
end
end
annotations.map! { |_, prefix, message| {1, prefix, message} } if lines.empty?
new(lines, annotations)
end
# NOTE: Annotations are sorted so that reconstructing the annotation
# text via `#to_s` is deterministic.
def initialize(@lines, annotations : Enumerable({Int32, String, String}))
@annotations = annotations.to_a.sort_by do |line, _, message|
{line, message}
end
end
# Annotates the source code with the Ameba issues provided.
#
# NOTE: Annotations are sorted so that reconstructing the annotation
# text via `#to_s` is deterministic.
def initialize(@lines, issues : Enumerable(Issue))
@annotations = issues_to_annotations(issues).sort_by do |line, _, message|
{line, message}
end
end
def ==(other)
other.is_a?(AnnotatedSource) && other.lines == lines && match_annotations?(other)
end
private def match_annotations?(other)
return false unless annotations.size == other.annotations.size
annotations.zip(other.annotations) do |(actual_line, actual_prefix, actual_message), (expected_line, expected_prefix, expected_message)|
return false unless actual_line == expected_line
return false unless expected_prefix.empty? || actual_prefix == expected_prefix
next if actual_message == expected_message
return false unless expected_message.includes?(ABBREV)
regex = /\A#{message_to_regex(expected_message)}\Z/
return false unless actual_message.matches?(regex)
end
true
end
private def message_to_regex(expected_annotation)
String.build do |io|
offset = 0
while index = expected_annotation.index(ABBREV, offset)
io << Regex.escape(expected_annotation[offset...index])
io << ".*?"
offset = index + ABBREV.size
end
io << Regex.escape(expected_annotation[offset..])
end
end
# Constructs an annotated source string (like what we parse).
#
# Reconstructs a deterministic annotated source string. This is
# useful for eliminating semantically irrelevant annotation
# ordering differences.
#
# source1 = AnnotatedSource.parse(<<-CRYSTAL)
# line1
# ^ Annotation 1
# ^^ Annotation 2
# CRYSTAL
#
# source2 = AnnotatedSource.parse(<<-CRYSTAL)
# line1
# ^^ Annotation 2
# ^ Annotation 1
# CRYSTAL
#
# source1.to_s == source2.to_s # => true
def to_s(io)
reconstructed = lines.dup
annotations.reverse_each do |line_number, prefix, message|
if prefix.empty?
reconstructed[line_number - 1] += "#{ANNOTATION_PATTERN_2}#{message}"
else
line_number = 0 if lines.empty?
reconstructed.insert(line_number, "#{prefix}#{message}")
end
end
io << reconstructed.join('\n')
end
private def issues_to_annotations(issues)
issues.map do |issue|
line, column, end_line, end_column = validate_location(issue)
indent_count = column - 3
indent = if indent_count < 0
""
else
" " * indent_count
end
caret_count = column_length(line, column, end_line, end_column)
caret_count += indent_count if indent_count < 0
carets = if caret_count <= 0
"^{}"
else
"^" * caret_count
end
{line, "#{indent}# #{carets} error: ", issue.message}
end
end
private def validate_location(issue)
loc, end_loc = issue.location, issue.end_location
raise "Missing location for issue '#{issue.message}'" unless loc
line, column = loc.line_number, loc.column_number
if line > lines.size || line < 1 || column < 1
raise "Invalid issue location: #{loc}"
end
if end_loc
if end_loc < loc
raise <<-MSG
Invalid issue location
start: #{loc}
end: #{end_loc}
MSG
end
end_line, end_column = end_loc.line_number, end_loc.column_number
if end_line > lines.size || end_line < 1 || end_column < 1
raise "Invalid issue end location: #{end_loc}"
end
end
{line, column, end_line, end_column}
end
private def column_length(line, column, end_line, end_column)
return 1 unless end_line && end_column
if line < end_line
code_line = lines[line - 1]
end_column = code_line.size
end
end_column - column + 1
end
end