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.
Files changed (81) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +328 -0
  3. data/config/default.yml +374 -0
  4. data/docs/remlint-rules.md +390 -0
  5. data/docs/remlint.md +238 -0
  6. data/exe/remlint +8 -0
  7. data/lib/remlint/cli.rb +300 -0
  8. data/lib/remlint/command.rb +229 -0
  9. data/lib/remlint/config.rb +255 -0
  10. data/lib/remlint/date_literal.rb +283 -0
  11. data/lib/remlint/document.rb +161 -0
  12. data/lib/remlint/expr_lexer.rb +197 -0
  13. data/lib/remlint/extractors.rb +210 -0
  14. data/lib/remlint/formatter.rb +96 -0
  15. data/lib/remlint/invocation.rb +145 -0
  16. data/lib/remlint/logical_line.rb +194 -0
  17. data/lib/remlint/offense.rb +117 -0
  18. data/lib/remlint/rule.rb +216 -0
  19. data/lib/remlint/rules/addomit_without_scanfrom.rb +189 -0
  20. data/lib/remlint/rules/advance_warning_body.rb +176 -0
  21. data/lib/remlint/rules/banner_placement.rb +126 -0
  22. data/lib/remlint/rules/calendar_text_limited.rb +125 -0
  23. data/lib/remlint/rules/callback_signature.rb +221 -0
  24. data/lib/remlint/rules/clause_needs_full_date.rb +176 -0
  25. data/lib/remlint/rules/clause_requires_at.rb +178 -0
  26. data/lib/remlint/rules/clause_value_range.rb +257 -0
  27. data/lib/remlint/rules/color_component_range.rb +295 -0
  28. data/lib/remlint/rules/coordinate_not_string.rb +175 -0
  29. data/lib/remlint/rules/dangling_continuation.rb +134 -0
  30. data/lib/remlint/rules/date_out_of_range.rb +180 -0
  31. data/lib/remlint/rules/debug_command.rb +189 -0
  32. data/lib/remlint/rules/easterdate_from_today.rb +160 -0
  33. data/lib/remlint/rules/function_arity.rb +385 -0
  34. data/lib/remlint/rules/function_redefinition.rb +159 -0
  35. data/lib/remlint/rules/generated_file_edited.rb +125 -0
  36. data/lib/remlint/rules/hebrew_date.rb +245 -0
  37. data/lib/remlint/rules/iftrig_with_satisfy.rb +101 -0
  38. data/lib/remlint/rules/include_path.rb +155 -0
  39. data/lib/remlint/rules/info_clause.rb +186 -0
  40. data/lib/remlint/rules/info_substitution_without_header.rb +169 -0
  41. data/lib/remlint/rules/invocation_mismatch.rb +223 -0
  42. data/lib/remlint/rules/keyword_case.rb +172 -0
  43. data/lib/remlint/rules/license_header.rb +101 -0
  44. data/lib/remlint/rules/line_length.rb +101 -0
  45. data/lib/remlint/rules/literal_type_mismatch.rb +324 -0
  46. data/lib/remlint/rules/localization_pack.rb +144 -0
  47. data/lib/remlint/rules/moon_phase_argument.rb +162 -0
  48. data/lib/remlint/rules/omit_aware_delta.rb +168 -0
  49. data/lib/remlint/rules/push_vars_missing_name.rb +158 -0
  50. data/lib/remlint/rules/repeat_trigger.rb +188 -0
  51. data/lib/remlint/rules/satisfy_constraint.rb +230 -0
  52. data/lib/remlint/rules/shell_maxlen.rb +170 -0
  53. data/lib/remlint/rules/shell_use_while_run_disabled.rb +172 -0
  54. data/lib/remlint/rules/string_escape.rb +158 -0
  55. data/lib/remlint/rules/syntax.rb +229 -0
  56. data/lib/remlint/rules/system_variable_assignment.rb +197 -0
  57. data/lib/remlint/rules/tag_syntax.rb +145 -0
  58. data/lib/remlint/rules/text_after_eof_marker.rb +134 -0
  59. data/lib/remlint/rules/time_zone_name.rb +212 -0
  60. data/lib/remlint/rules/tk_tag_namespace.rb +121 -0
  61. data/lib/remlint/rules/todo_complete_through.rb +122 -0
  62. data/lib/remlint/rules/trailing_whitespace.rb +116 -0
  63. data/lib/remlint/rules/translate_command.rb +203 -0
  64. data/lib/remlint/rules/unbalanced_blocks.rb +305 -0
  65. data/lib/remlint/rules/unbalanced_delimiters.rb +251 -0
  66. data/lib/remlint/rules/unknown_special_type.rb +155 -0
  67. data/lib/remlint/rules/unknown_substitution_sequence.rb +255 -0
  68. data/lib/remlint/rules/unknown_system_variable.rb +145 -0
  69. data/lib/remlint/rules/unquoted_shell_substitution.rb +266 -0
  70. data/lib/remlint/rules/until_before_from.rb +204 -0
  71. data/lib/remlint/rules/world_writable_script.rb +128 -0
  72. data/lib/remlint/rules.rb +60 -0
  73. data/lib/remlint/runner.rb +274 -0
  74. data/lib/remlint/source.rb +36 -0
  75. data/lib/remlint/tables.rb +395 -0
  76. data/lib/remlint/trigger.rb +359 -0
  77. data/lib/remlint/version.rb +5 -0
  78. data/lib/remlint/vocabulary.rb +236 -0
  79. data/lib/remlint.rb +35 -0
  80. data/tasks/generate_tables.rb +175 -0
  81. 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