remlint 0.1.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.
- checksums.yaml +7 -0
- data/LICENSE +328 -0
- data/config/default.yml +374 -0
- data/docs/remlint-rules.md +390 -0
- data/docs/remlint.md +238 -0
- data/exe/remlint +8 -0
- data/lib/remlint/cli.rb +300 -0
- data/lib/remlint/command.rb +229 -0
- data/lib/remlint/config.rb +255 -0
- data/lib/remlint/date_literal.rb +283 -0
- data/lib/remlint/document.rb +161 -0
- data/lib/remlint/expr_lexer.rb +197 -0
- data/lib/remlint/extractors.rb +210 -0
- data/lib/remlint/formatter.rb +96 -0
- data/lib/remlint/invocation.rb +145 -0
- data/lib/remlint/logical_line.rb +194 -0
- data/lib/remlint/offense.rb +117 -0
- data/lib/remlint/rule.rb +216 -0
- data/lib/remlint/rules/addomit_without_scanfrom.rb +189 -0
- data/lib/remlint/rules/advance_warning_body.rb +176 -0
- data/lib/remlint/rules/banner_placement.rb +126 -0
- data/lib/remlint/rules/calendar_text_limited.rb +125 -0
- data/lib/remlint/rules/callback_signature.rb +221 -0
- data/lib/remlint/rules/clause_needs_full_date.rb +176 -0
- data/lib/remlint/rules/clause_requires_at.rb +178 -0
- data/lib/remlint/rules/clause_value_range.rb +257 -0
- data/lib/remlint/rules/color_component_range.rb +295 -0
- data/lib/remlint/rules/coordinate_not_string.rb +175 -0
- data/lib/remlint/rules/dangling_continuation.rb +134 -0
- data/lib/remlint/rules/date_out_of_range.rb +180 -0
- data/lib/remlint/rules/debug_command.rb +189 -0
- data/lib/remlint/rules/easterdate_from_today.rb +160 -0
- data/lib/remlint/rules/function_arity.rb +385 -0
- data/lib/remlint/rules/function_redefinition.rb +159 -0
- data/lib/remlint/rules/generated_file_edited.rb +125 -0
- data/lib/remlint/rules/hebrew_date.rb +245 -0
- data/lib/remlint/rules/iftrig_with_satisfy.rb +101 -0
- data/lib/remlint/rules/include_path.rb +155 -0
- data/lib/remlint/rules/info_clause.rb +186 -0
- data/lib/remlint/rules/info_substitution_without_header.rb +169 -0
- data/lib/remlint/rules/invocation_mismatch.rb +223 -0
- data/lib/remlint/rules/keyword_case.rb +172 -0
- data/lib/remlint/rules/license_header.rb +101 -0
- data/lib/remlint/rules/line_length.rb +101 -0
- data/lib/remlint/rules/literal_type_mismatch.rb +324 -0
- data/lib/remlint/rules/localization_pack.rb +144 -0
- data/lib/remlint/rules/moon_phase_argument.rb +162 -0
- data/lib/remlint/rules/omit_aware_delta.rb +168 -0
- data/lib/remlint/rules/push_vars_missing_name.rb +158 -0
- data/lib/remlint/rules/repeat_trigger.rb +188 -0
- data/lib/remlint/rules/satisfy_constraint.rb +230 -0
- data/lib/remlint/rules/shell_maxlen.rb +170 -0
- data/lib/remlint/rules/shell_use_while_run_disabled.rb +172 -0
- data/lib/remlint/rules/string_escape.rb +158 -0
- data/lib/remlint/rules/syntax.rb +229 -0
- data/lib/remlint/rules/system_variable_assignment.rb +197 -0
- data/lib/remlint/rules/tag_syntax.rb +145 -0
- data/lib/remlint/rules/text_after_eof_marker.rb +134 -0
- data/lib/remlint/rules/time_zone_name.rb +212 -0
- data/lib/remlint/rules/tk_tag_namespace.rb +121 -0
- data/lib/remlint/rules/todo_complete_through.rb +122 -0
- data/lib/remlint/rules/trailing_whitespace.rb +116 -0
- data/lib/remlint/rules/translate_command.rb +203 -0
- data/lib/remlint/rules/unbalanced_blocks.rb +305 -0
- data/lib/remlint/rules/unbalanced_delimiters.rb +251 -0
- data/lib/remlint/rules/unknown_special_type.rb +155 -0
- data/lib/remlint/rules/unknown_substitution_sequence.rb +255 -0
- data/lib/remlint/rules/unknown_system_variable.rb +145 -0
- data/lib/remlint/rules/unquoted_shell_substitution.rb +266 -0
- data/lib/remlint/rules/until_before_from.rb +204 -0
- data/lib/remlint/rules/world_writable_script.rb +128 -0
- data/lib/remlint/rules.rb +60 -0
- data/lib/remlint/runner.rb +274 -0
- data/lib/remlint/source.rb +36 -0
- data/lib/remlint/tables.rb +395 -0
- data/lib/remlint/trigger.rb +359 -0
- data/lib/remlint/version.rb +5 -0
- data/lib/remlint/vocabulary.rb +236 -0
- data/lib/remlint.rb +35 -0
- data/tasks/generate_tables.rb +175 -0
- metadata +170 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rule"
|
|
4
|
+
|
|
5
|
+
module RemLint
|
|
6
|
+
module Rules
|
|
7
|
+
# Block commands that never close, close the wrong thing, or close nothing.
|
|
8
|
+
#
|
|
9
|
+
# Remind has its own "Warning: Missing ENDIF" (src/err.h E_MISS_ENDIF), but
|
|
10
|
+
# only for the file it actually reaches the end of, and only once it has
|
|
11
|
+
# run everything above. A linter can say it before anything runs, name the
|
|
12
|
+
# line the block opened on, and cover the three context stacks Remind's own
|
|
13
|
+
# warning does not:
|
|
14
|
+
#
|
|
15
|
+
# IF / IFTRIG ... ELSE ... ENDIF
|
|
16
|
+
# PUSH-OMIT-CONTEXT ... POP-OMIT-CONTEXT
|
|
17
|
+
# PUSH-VARS ... POP-VARS
|
|
18
|
+
# PUSH-FUNCS ... POP-FUNCS
|
|
19
|
+
#
|
|
20
|
+
# `defs.rem` nests IF inside ELSE inside IF for its Yom Hazikaron
|
|
21
|
+
# calculation and brackets its US Tax Day block in PUSH/POP-OMIT-CONTEXT,
|
|
22
|
+
# so all of this is load-bearing in the shipped examples.
|
|
23
|
+
#
|
|
24
|
+
# Matching is by canonical keyword, so the abbreviations Remind allows --
|
|
25
|
+
# `PUSH` for PUSH-OMIT-CONTEXT, `POP` for POP-OMIT-CONTEXT -- are matched
|
|
26
|
+
# like the long forms rather than missed.
|
|
27
|
+
class UnbalancedBlocks < Rule
|
|
28
|
+
PAIRS = {
|
|
29
|
+
"IF" => "ENDIF",
|
|
30
|
+
"IFTRIG" => "ENDIF",
|
|
31
|
+
"PUSH-OMIT-CONTEXT" => "POP-OMIT-CONTEXT",
|
|
32
|
+
"PUSH-VARS" => "POP-VARS",
|
|
33
|
+
"PUSH-FUNCS" => "POP-FUNCS",
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
CLOSERS = PAIRS.values.uniq.freeze
|
|
37
|
+
|
|
38
|
+
# `IF_NEST` in src/ifelse.c. The 65th level is an error rather than a
|
|
39
|
+
# deeper nesting, and the linter can say so before the file runs -- though
|
|
40
|
+
# a script at that depth has larger problems than this rule.
|
|
41
|
+
MAX_IF_DEPTH = 64
|
|
42
|
+
|
|
43
|
+
# Which openers a given closer is allowed to close.
|
|
44
|
+
OPENERS_FOR = CLOSERS.to_h do |closer|
|
|
45
|
+
[closer, PAIRS.select { |_opener, close| close == closer }.keys]
|
|
46
|
+
end.freeze
|
|
47
|
+
|
|
48
|
+
def self.default_severity
|
|
49
|
+
"error"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.description
|
|
53
|
+
"IF/ENDIF, ELSE and the PUSH/POP context commands that do not pair up."
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def check
|
|
57
|
+
@stack = []
|
|
58
|
+
|
|
59
|
+
document.code_commands.each do |command|
|
|
60
|
+
dispatch(command)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
report_unclosed
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def dispatch(command)
|
|
69
|
+
if PAIRS.key?(keyword_of(command))
|
|
70
|
+
@stack.push({ command: command, else_line: nil })
|
|
71
|
+
check_depth(command)
|
|
72
|
+
elsif CLOSERS.include?(keyword_of(command))
|
|
73
|
+
close(command)
|
|
74
|
+
elsif command.keyword?("ELSE")
|
|
75
|
+
handle_else(command)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def keyword_of(command)
|
|
80
|
+
command.keyword&.name
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def check_depth(command)
|
|
84
|
+
depth = @stack.count { |frame| conditional?(frame) }
|
|
85
|
+
|
|
86
|
+
if depth == MAX_IF_DEPTH + 1 && conditional?(@stack.last)
|
|
87
|
+
offend(
|
|
88
|
+
command.line,
|
|
89
|
+
"`IF` blocks nest #{MAX_IF_DEPTH} deep at most; this is level #{depth}",
|
|
90
|
+
column: command.keyword_column,
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def close(command)
|
|
96
|
+
closer = keyword_of(command)
|
|
97
|
+
frame = @stack.last
|
|
98
|
+
|
|
99
|
+
if frame.nil? || !OPENERS_FOR.fetch(closer).include?(keyword_of(frame[:command]))
|
|
100
|
+
report_stray(command, closer, frame)
|
|
101
|
+
else
|
|
102
|
+
@stack.pop
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# A closer that matches nothing on the stack is one of two different
|
|
107
|
+
# mistakes, and the message says which: nothing is open at all, or
|
|
108
|
+
# something else is open and this closer crosses it.
|
|
109
|
+
def report_stray(command, closer, frame)
|
|
110
|
+
if frame.nil?
|
|
111
|
+
offend(
|
|
112
|
+
command.line,
|
|
113
|
+
"`#{closer}` with nothing open to close",
|
|
114
|
+
column: command.keyword_column,
|
|
115
|
+
)
|
|
116
|
+
else
|
|
117
|
+
opener = frame[:command]
|
|
118
|
+
|
|
119
|
+
offend(
|
|
120
|
+
command.line,
|
|
121
|
+
"`#{closer}` closes `#{keyword_of(opener)}` opened on line #{opener.line}",
|
|
122
|
+
column: command.keyword_column,
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def handle_else(command)
|
|
128
|
+
frame = @stack.reverse.find { |candidate| conditional?(candidate) }
|
|
129
|
+
|
|
130
|
+
if frame.nil?
|
|
131
|
+
offend(command.line, "`ELSE` outside any `IF` block", column: command.keyword_column)
|
|
132
|
+
elsif frame[:else_line]
|
|
133
|
+
offend(
|
|
134
|
+
command.line,
|
|
135
|
+
"`ELSE` already given for this `IF` on line #{frame[:else_line]}",
|
|
136
|
+
column: command.keyword_column,
|
|
137
|
+
)
|
|
138
|
+
else
|
|
139
|
+
frame[:else_line] = command.line
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def conditional?(frame)
|
|
144
|
+
PAIRS[keyword_of(frame[:command])] == "ENDIF"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def report_unclosed
|
|
148
|
+
@stack.each do |frame|
|
|
149
|
+
opener = frame[:command]
|
|
150
|
+
keyword = keyword_of(opener)
|
|
151
|
+
|
|
152
|
+
offend(
|
|
153
|
+
opener.line,
|
|
154
|
+
"`#{keyword}` is never closed by `#{PAIRS.fetch(keyword)}`",
|
|
155
|
+
column: opener.keyword_column,
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
__END__
|
|
164
|
+
|
|
165
|
+
require_relative "../document"
|
|
166
|
+
|
|
167
|
+
describe "RemLint::Rules::UnbalancedBlocks" do
|
|
168
|
+
lint = proc do |text|
|
|
169
|
+
source = RemLint::Source.new(path: "t.rem", text: text)
|
|
170
|
+
|
|
171
|
+
RemLint::Rules::UnbalancedBlocks.new.run(RemLint::Document.new(source))
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
messages = proc { |text| lint.(text).map(&:message) }
|
|
175
|
+
|
|
176
|
+
describe "balanced code" do
|
|
177
|
+
it "accepts a simple conditional" do
|
|
178
|
+
lint.("IF a\n MSG yes\nENDIF\n").should.be.empty
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
it "accepts an ELSE" do
|
|
182
|
+
lint.("IF a\n MSG yes\nELSE\n MSG no\nENDIF\n").should.be.empty
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
it "accepts nesting, as defs.rem does" do
|
|
186
|
+
lint.("IF a\nIF b\nMSG x\nELSE\nMSG y\nENDIF\nELSE\nMSG z\nENDIF\n").should.be.empty
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
it "accepts IFTRIG" do
|
|
190
|
+
lint.("IFTRIG 1 Jan\n MSG hi\nENDIF\n").should.be.empty
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
it "accepts the omit-context stack" do
|
|
194
|
+
lint.("PUSH-OMIT-CONTEXT\nOMIT 1 Jan\nPOP-OMIT-CONTEXT\n").should.be.empty
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
it "accepts the abbreviations Remind allows for it" do
|
|
198
|
+
lint.("PUSH\nOMIT 1 Jan\nPOP\n").should.be.empty
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
it "accepts the vars and funcs stacks" do
|
|
202
|
+
lint.("PUSH-VARS\nSET a 1\nPOP-VARS\n").should.be.empty
|
|
203
|
+
lint.("PUSH-FUNCS\nFSET f(x) x\nPOP-FUNCS\n").should.be.empty
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
it "is not confused by a comment or a blank line inside a block" do
|
|
207
|
+
lint.("IF a\n\n# note\nMSG x\nENDIF\n").should.be.empty
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
describe "an unclosed opener" do
|
|
212
|
+
it "is reported on the line it opened" do
|
|
213
|
+
offenses = lint.("IF $TerminalBackground == 0\n MSG dark\n")
|
|
214
|
+
|
|
215
|
+
offenses.length.should == 1
|
|
216
|
+
offenses.first.line.should == 1
|
|
217
|
+
offenses.first.message.should.match(/never closed by `ENDIF`/)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
it "is reported for each of several" do
|
|
221
|
+
messages.("IF a\nIF b\nMSG x\n").length.should == 2
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
it "names the right closer for a context stack" do
|
|
225
|
+
messages.("PUSH-VARS\nSET a 1\n").first.should.match(/never closed by `POP-VARS`/)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
it "points at the keyword's column" do
|
|
229
|
+
lint.(" IF a\n").first.column.should == 4
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
describe "a closer with nothing open" do
|
|
234
|
+
it "is reported" do
|
|
235
|
+
messages.("MSG hi\nENDIF\n").should == ["`ENDIF` with nothing open to close"]
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
it "is reported for a stray POP too" do
|
|
239
|
+
messages.("POP-OMIT-CONTEXT\n").first.should.match(/nothing open/)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
describe "a closer that crosses another block" do
|
|
244
|
+
it "names the block it crossed and where that opened" do
|
|
245
|
+
messages.("IF a\nPOP-OMIT-CONTEXT\nENDIF\n").first.should ==
|
|
246
|
+
"`POP-OMIT-CONTEXT` closes `IF` opened on line 1"
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
describe "ELSE" do
|
|
251
|
+
it "outside any IF is reported" do
|
|
252
|
+
messages.("MSG hi\nELSE\nMSG bye\n").should == ["`ELSE` outside any `IF` block"]
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
it "inside an omit-context block but no IF is reported" do
|
|
256
|
+
messages.("PUSH-OMIT-CONTEXT\nELSE\nPOP-OMIT-CONTEXT\n").should ==
|
|
257
|
+
["`ELSE` outside any `IF` block"]
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
it "given twice for one IF is reported the second time" do
|
|
261
|
+
offenses = lint.("IF a\nMSG x\nELSE\nMSG y\nELSE\nMSG z\nENDIF\n")
|
|
262
|
+
|
|
263
|
+
offenses.length.should == 1
|
|
264
|
+
offenses.first.line.should == 5
|
|
265
|
+
offenses.first.message.should.match(/already given/)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
it "is allowed once in each of two nested IFs" do
|
|
269
|
+
lint.("IF a\nIF b\nMSG x\nELSE\nMSG y\nENDIF\nELSE\nMSG z\nENDIF\n").should.be.empty
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
describe "nesting depth" do
|
|
274
|
+
it "accepts 64 levels" do
|
|
275
|
+
text = ("IF a\n" * 64) + "MSG hi\n" + ("ENDIF\n" * 64)
|
|
276
|
+
|
|
277
|
+
lint.(text).should.be.empty
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
it "reports the 65th" do
|
|
281
|
+
text = ("IF a\n" * 65) + "MSG hi\n" + ("ENDIF\n" * 65)
|
|
282
|
+
offenses = lint.(text)
|
|
283
|
+
|
|
284
|
+
offenses.length.should == 1
|
|
285
|
+
offenses.first.line.should == 65
|
|
286
|
+
offenses.first.message.should.match(/nest 64 deep at most; this is level 65/)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
it "does not count the context stacks toward the IF limit" do
|
|
290
|
+
text = ("PUSH-OMIT-CONTEXT\n" * 70) + ("POP-OMIT-CONTEXT\n" * 70)
|
|
291
|
+
|
|
292
|
+
lint.(text).should.be.empty
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
it "reports at error severity" do
|
|
297
|
+
lint.("IF a\n").first.severity.should == "error"
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
it "reports lines of a heredoc at their position in the enclosing file" do
|
|
301
|
+
source = RemLint::Source.new(path: "astro", text: "IF a\nMSG x\n", line_offset: 12)
|
|
302
|
+
|
|
303
|
+
RemLint::Rules::UnbalancedBlocks.new.run(RemLint::Document.new(source)).first.line.should == 13
|
|
304
|
+
end
|
|
305
|
+
end
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rule"
|
|
4
|
+
|
|
5
|
+
module RemLint
|
|
6
|
+
module Rules
|
|
7
|
+
# Brackets and parentheses that never close, or that close each other.
|
|
8
|
+
#
|
|
9
|
+
# Remind has `Missing ']'` and `Missing ')'` (src/err.h E_MISS_END,
|
|
10
|
+
# E_MISS_RIGHT_PAREN), so this reports something Remind agrees is wrong --
|
|
11
|
+
# it just reports it without running the file, and names the line the
|
|
12
|
+
# delimiter opened on rather than the line parsing gave up at.
|
|
13
|
+
#
|
|
14
|
+
# Two asymmetries matter, and both come from Remind treating unbracketed
|
|
15
|
+
# text as text:
|
|
16
|
+
#
|
|
17
|
+
# A CLOSER WITH NOTHING OPEN IS NOT AN ERROR. `MSG See note ]` prints a
|
|
18
|
+
# bracket. Reporting it would make the rule unusable on message bodies,
|
|
19
|
+
# which is most of what a reminder file is.
|
|
20
|
+
#
|
|
21
|
+
# PARENTHESES ONLY COUNT INSIDE AN EXPRESSION. `MSG Call (555) 1234` is
|
|
22
|
+
# text, not a call with a missing operand. So parentheses are tracked
|
|
23
|
+
# inside `[...]`, and in the arguments of the commands whose arguments are
|
|
24
|
+
# expressions -- `IF`, `SET`, `FSET` and friends -- and nowhere else.
|
|
25
|
+
#
|
|
26
|
+
# String literals are skipped, because the lexer already took them whole:
|
|
27
|
+
# the `]` in `MSG a "]" b` is inside a string and closes nothing.
|
|
28
|
+
class UnbalancedDelimiters < Rule
|
|
29
|
+
# Commands whose arguments Remind evaluates as an expression, so that
|
|
30
|
+
# parentheses in them are syntax rather than punctuation.
|
|
31
|
+
EXPRESSION_COMMANDS = %w[
|
|
32
|
+
IF IFTRIG SET FSET OMITFUNC EXPR MAX-OVERDUE
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
CLOSER_FOR = { lbracket: :rbracket, lparen: :rparen }.freeze
|
|
36
|
+
GLYPH = { lbracket: "[", rbracket: "]", lparen: "(", rparen: ")" }.freeze
|
|
37
|
+
|
|
38
|
+
def self.default_severity
|
|
39
|
+
"error"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.description
|
|
43
|
+
"Brackets and parentheses that are never closed, or that close each other."
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def check
|
|
47
|
+
document.logical_lines.each_with_index do |logical_line, index|
|
|
48
|
+
command = document.commands[index]
|
|
49
|
+
|
|
50
|
+
if command.code?
|
|
51
|
+
check_line(logical_line, command)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def check_line(logical_line, command)
|
|
59
|
+
stack = []
|
|
60
|
+
expression = expression_command?(command)
|
|
61
|
+
|
|
62
|
+
document.tokens_for(logical_line).each do |token|
|
|
63
|
+
step(
|
|
64
|
+
stack,
|
|
65
|
+
token,
|
|
66
|
+
logical_line,
|
|
67
|
+
expression,
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
report_unclosed(stack, logical_line)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def step(stack, token, logical_line, expression)
|
|
75
|
+
if opener?(token, stack, expression)
|
|
76
|
+
stack.push(token)
|
|
77
|
+
elsif closer?(token, stack, expression)
|
|
78
|
+
match(stack, token, logical_line)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def opener?(token, stack, expression)
|
|
83
|
+
token.type == :lbracket ||
|
|
84
|
+
(token.type == :lparen && parens_count?(stack, expression))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def closer?(token, stack, expression)
|
|
88
|
+
token.type == :rbracket ||
|
|
89
|
+
(token.type == :rparen && parens_count?(stack, expression))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Inside a bracketed expression, or in the arguments of a command whose
|
|
93
|
+
# arguments are one.
|
|
94
|
+
def parens_count?(stack, expression)
|
|
95
|
+
expression || stack.any? { |open| open.type == :lbracket }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def match(stack, token, logical_line)
|
|
99
|
+
top = stack.last
|
|
100
|
+
|
|
101
|
+
if top.nil?
|
|
102
|
+
nil # A closer with nothing open is literal text, not an error.
|
|
103
|
+
elsif CLOSER_FOR.fetch(top.type) == token.type
|
|
104
|
+
stack.pop
|
|
105
|
+
else
|
|
106
|
+
report_crossed(stack, token, logical_line)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def report_crossed(stack, token, logical_line)
|
|
111
|
+
top = stack.pop
|
|
112
|
+
line, _column = logical_line.position_at(top.offset)
|
|
113
|
+
|
|
114
|
+
offend_at(
|
|
115
|
+
logical_line,
|
|
116
|
+
token.offset,
|
|
117
|
+
"`#{GLYPH.fetch(token.type)}` closes `#{GLYPH.fetch(top.type)}` opened " \
|
|
118
|
+
"on line #{line}",
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def report_unclosed(stack, logical_line)
|
|
123
|
+
stack.each do |token|
|
|
124
|
+
offend_at(
|
|
125
|
+
logical_line,
|
|
126
|
+
token.offset,
|
|
127
|
+
"`#{GLYPH.fetch(token.type)}` is never closed by " \
|
|
128
|
+
"`#{GLYPH.fetch(CLOSER_FOR.fetch(token.type))}`",
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def expression_command?(command)
|
|
134
|
+
command.keyword?(*EXPRESSION_COMMANDS)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
__END__
|
|
141
|
+
|
|
142
|
+
require_relative "../document"
|
|
143
|
+
|
|
144
|
+
describe "RemLint::Rules::UnbalancedDelimiters" do
|
|
145
|
+
lint = proc do |text|
|
|
146
|
+
source = RemLint::Source.new(path: "t.rem", text: text)
|
|
147
|
+
|
|
148
|
+
RemLint::Rules::UnbalancedDelimiters.new.run(RemLint::Document.new(source))
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
messages = proc { |text| lint.(text).map(&:message) }
|
|
152
|
+
|
|
153
|
+
describe "balanced code" do
|
|
154
|
+
it "accepts a bracketed substitution" do
|
|
155
|
+
lint.("MSG This is [center(\"hello\")]\n").should.be.empty
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it "accepts the nested calls alignment.rem uses" do
|
|
159
|
+
lint.("MSG [ansicolor(255,255,0) + center(\"x \") + ansicolor(\"\")]\n").should.be.empty
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it "accepts an expression command with parentheses and no brackets" do
|
|
163
|
+
lint.("IF version() < \"03.04.02\"\n").should.be.empty
|
|
164
|
+
lint.("FSET center(x) pad(\"\", \" \", (columns() - columns(x))/2) + x\n").should.be.empty
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
it "accepts a system-include path" do
|
|
168
|
+
lint.("INCLUDE [$SysInclude]/ansitext.rem\n").should.be.empty
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
it "accepts brackets spread over a continuation" do
|
|
172
|
+
lint.("SET x [IIF(a, \\\n b, \\\n c)]\n").should.be.empty
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
describe "text that only looks unbalanced" do
|
|
177
|
+
it "leaves a closer with nothing open alone" do
|
|
178
|
+
lint.("MSG See note ]\n").should.be.empty
|
|
179
|
+
lint.("MSG Call me )\n").should.be.empty
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
it "leaves parentheses in a message body alone" do
|
|
183
|
+
lint.("MSG Call (555 1234 tomorrow\n").should.be.empty
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
it "leaves parentheses in a RUN body alone" do
|
|
187
|
+
lint.("RUN echo $(date\n").should.be.empty
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
it "does not count a bracket inside a string literal" do
|
|
191
|
+
lint.(%(MSG a "]" b\n)).should.be.empty
|
|
192
|
+
lint.(%(MSG a "[" b\n)).should.be.empty
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
describe "an unclosed bracket" do
|
|
197
|
+
it "is reported" do
|
|
198
|
+
messages.("MSG value is [x\n").should == ["`[` is never closed by `]`"]
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
it "points at the bracket" do
|
|
202
|
+
lint.("MSG value is [x\n").first.column.should == 14
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
it "is reported once per unclosed bracket" do
|
|
206
|
+
messages.("MSG [a [b\n").length.should == 2
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
it "is reported on the physical line it opened on" do
|
|
210
|
+
lint.("SET x 1 + \\\n [y\n").first.line.should == 2
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
describe "an unclosed parenthesis" do
|
|
215
|
+
it "is reported inside a bracketed expression" do
|
|
216
|
+
messages.("MSG [center(\"x\"\n").should ==
|
|
217
|
+
["`[` is never closed by `]`", "`(` is never closed by `)`"]
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
it "is reported in an expression command" do
|
|
221
|
+
messages.("IF trigger(x\n").should == ["`(` is never closed by `)`"]
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
describe "delimiters that cross" do
|
|
226
|
+
it "reports a bracket closed by a parenthesis" do
|
|
227
|
+
messages.("SET x (a]\n").should == ["`]` closes `(` opened on line 1"]
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
it "reports a parenthesis closed by a bracket" do
|
|
231
|
+
# The `]` crosses the `(`, and the `[` it was meant to close is then
|
|
232
|
+
# left open -- both halves of the mistake get said.
|
|
233
|
+
messages.("MSG [foo(]\n").should ==
|
|
234
|
+
["`]` closes `(` opened on line 1", "`[` is never closed by `]`"]
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
it "reports at error severity" do
|
|
239
|
+
lint.("MSG [x\n").first.severity.should == "error"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
it "says nothing about comments" do
|
|
243
|
+
lint.("# a note with [ and (\n").should.be.empty
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
it "reports lines of a heredoc at their position in the enclosing file" do
|
|
247
|
+
source = RemLint::Source.new(path: "astro", text: "MSG [x\n", line_offset: 12)
|
|
248
|
+
|
|
249
|
+
RemLint::Rules::UnbalancedDelimiters.new.run(RemLint::Document.new(source)).first.line.should == 13
|
|
250
|
+
end
|
|
251
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rule"
|
|
4
|
+
|
|
5
|
+
module RemLint
|
|
6
|
+
module Rules
|
|
7
|
+
# `SPECIAL` types no shipped back-end understands.
|
|
8
|
+
#
|
|
9
|
+
# Remind passes a `SPECIAL` through untouched and the back-end is required
|
|
10
|
+
# to ignore anything it cannot parse. That contract is what makes `SPECIAL`
|
|
11
|
+
# extensible, and it is also why a typo is invisible: `SPECIAL SHDE 255 255
|
|
12
|
+
# 204` produces no error, no warning and no shading. The reminder triggers,
|
|
13
|
+
# the back-end shrugs, and the day is simply not coloured in.
|
|
14
|
+
#
|
|
15
|
+
# The shipped set is what the back-ends actually test `passthru` against --
|
|
16
|
+
# including `PostScript` and `PSFile`, which rem2ps reads and which are
|
|
17
|
+
# easy to forget because they look like reminder types rather than SPECIAL
|
|
18
|
+
# ones. A project with its own back-end adds to the set with
|
|
19
|
+
# `AllowedTypes`, which is the whole point of the passthrough.
|
|
20
|
+
class UnknownSpecialType < Rule
|
|
21
|
+
# Every value the shipped back-ends compare `passthru` against: the
|
|
22
|
+
# calendar ones from src/calendar.c and src/rem2ps.c, and the HTML and
|
|
23
|
+
# Pango ones from rem2html and Remind::PDF.
|
|
24
|
+
KNOWN = %w[
|
|
25
|
+
SHADE MOON WEEK COLOR COLOUR
|
|
26
|
+
POSTSCRIPT PSFILE PS
|
|
27
|
+
PANGO HTML HTMLCLASS
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
def self.default_severity
|
|
31
|
+
"warning"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.description
|
|
35
|
+
"A SPECIAL type no shipped back-end parses."
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def check
|
|
39
|
+
allowed = KNOWN + option("AllowedTypes", []).map(&:upcase)
|
|
40
|
+
|
|
41
|
+
document.code_commands.each do |command|
|
|
42
|
+
check_special(command, allowed)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def check_special(command, allowed)
|
|
49
|
+
trigger = document.trigger_for(command)
|
|
50
|
+
|
|
51
|
+
if trigger.body&.name == "SPECIAL"
|
|
52
|
+
report(command, trigger, allowed)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def report(command, trigger, allowed)
|
|
57
|
+
tokens = document.tokens_for(command.logical_line)
|
|
58
|
+
token = tokens[trigger.body.argument_index]
|
|
59
|
+
|
|
60
|
+
if token&.type == :name && !allowed.include?(token.value.upcase)
|
|
61
|
+
offend_at(command.logical_line, token.offset, message(token, allowed))
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def message(token, allowed)
|
|
66
|
+
"`SPECIAL #{token.value}` is a type no shipped back-end parses, so it " \
|
|
67
|
+
"produces no output and no error; the shipped set is #{allowed.join(', ')}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
__END__
|
|
74
|
+
|
|
75
|
+
require_relative "../document"
|
|
76
|
+
|
|
77
|
+
describe "RemLint::Rules::UnknownSpecialType" do
|
|
78
|
+
lint = proc do |text, config = {}|
|
|
79
|
+
source = RemLint::Source.new(path: "t.rem", text: text)
|
|
80
|
+
|
|
81
|
+
RemLint::Rules::UnknownSpecialType.new(config).run(RemLint::Document.new(source))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
messages = proc { |text, config = {}| lint.(text, config).map(&:message) }
|
|
85
|
+
|
|
86
|
+
describe "types the back-ends parse" do
|
|
87
|
+
it "accepts SHADE and MOON" do
|
|
88
|
+
lint.("REM Mon SPECIAL SHADE 255 255 204\n").should.be.empty
|
|
89
|
+
lint.("REM Mon SPECIAL MOON 0\n").should.be.empty
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it "accepts COLOR and its other spelling" do
|
|
93
|
+
lint.("REM Mon SPECIAL COLOR 255 0 0 Sunset\n").should.be.empty
|
|
94
|
+
lint.("REM Mon SPECIAL COLOUR 255 0 0 Sunset\n").should.be.empty
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it "accepts the PostScript passthroughs rem2ps reads" do
|
|
98
|
+
# tests/test2.rem writes this one, and rem2ps.c compares against it.
|
|
99
|
+
lint.("REM 21 AUG AT 1:55 SPECIAL PostScript (wookie) show\n").should.be.empty
|
|
100
|
+
lint.("REM Mon SPECIAL PSFile /tmp/x.ps\n").should.be.empty
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it "accepts WEEK, PANGO and HTML" do
|
|
104
|
+
lint.("REM Mon SPECIAL WEEK 5\n").should.be.empty
|
|
105
|
+
lint.("REM Mon SPECIAL PANGO <b>bold</b>\n").should.be.empty
|
|
106
|
+
lint.("REM Mon SPECIAL HTML <b>bold</b>\n").should.be.empty
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it "is case-insensitive" do
|
|
110
|
+
lint.("REM Mon SPECIAL shade 255 255 204\n").should.be.empty
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
describe "types nothing parses" do
|
|
115
|
+
it "reports a typo" do
|
|
116
|
+
messages.("REM Mon SPECIAL SHDE 255 255 204\n").first.should.match(
|
|
117
|
+
/`SPECIAL SHDE` is a type no shipped back-end parses/,
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it "explains the silence" do
|
|
122
|
+
messages.("REM Mon SPECIAL SHDE 1\n").first.should.match(
|
|
123
|
+
/produces no output and no error/,
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it "points at the type" do
|
|
128
|
+
text = "REM Mon SPECIAL SHDE 1\n"
|
|
129
|
+
|
|
130
|
+
lint.(text).first.column.should == text.index("SHDE") + 1
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
describe "AllowedTypes" do
|
|
135
|
+
it "accepts a type the configuration adds" do
|
|
136
|
+
lint.("REM Mon SPECIAL MYTHING 1\n", "AllowedTypes" => ["MYTHING"]).should.be.empty
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it "matches an added type case-insensitively" do
|
|
140
|
+
lint.("REM Mon SPECIAL mything 1\n", "AllowedTypes" => ["MyThing"]).should.be.empty
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
it "says nothing about a reminder that is not a SPECIAL" do
|
|
145
|
+
lint.("REM Mon MSG SPECIAL SHDE\n").should.be.empty
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it "says nothing about comments" do
|
|
149
|
+
lint.("# REM Mon SPECIAL SHDE 1\n").should.be.empty
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
it "reports at warning severity" do
|
|
153
|
+
lint.("REM Mon SPECIAL SHDE 1\n").first.severity.should == "warning"
|
|
154
|
+
end
|
|
155
|
+
end
|