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,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module RemLint
6
+ module Rules
7
+ # `easterdate(today())` in a trigger that adds an offset.
8
+ #
9
+ # `easterdate(date)` returns the *next* Easter on or after that date. So on
10
+ # Easter Sunday it returns today, and on Easter Monday it returns Easter of
11
+ # the following year. A reminder written
12
+ #
13
+ # REM [easterdate(today())+1] MSG Easter Monday
14
+ #
15
+ # therefore never triggers: on Easter Monday the expression has already
16
+ # jumped a year ahead, and the day it names is 364 days away.
17
+ #
18
+ # `easterdate(year)` -- the integer form -- returns Easter of that year and
19
+ # does not move, which is why the fix is `easterdate(year(today()))`.
20
+ #
21
+ # The same reasoning applies to `orthodoxeaster`, which has the same two
22
+ # forms.
23
+ class EasterdateFromToday < Rule
24
+ FUNCTIONS = %w[easterdate orthodoxeaster].freeze
25
+
26
+ TODAY = %w[today now realtoday].freeze
27
+
28
+ def self.default_severity
29
+ "warning"
30
+ end
31
+
32
+ def self.description
33
+ "easterdate(today()) in a trigger with an offset, which skips a year on the day."
34
+ end
35
+
36
+ def check
37
+ document.code_commands.each do |command|
38
+ if offset_trigger?(command)
39
+ check_calls(command)
40
+ end
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ # An offset only matters inside the trigger, and only when there is one
47
+ # to matter: `REM [easterdate(today())] MSG Easter` is correct.
48
+ def offset_trigger?(command)
49
+ trigger = document.trigger_for(command)
50
+ limit = trigger.body_offset || command.text.length
51
+ tokens = document.tokens_for(command.logical_line)
52
+
53
+ trigger.triggered? && tokens.any? { |token| offset?(token, limit) }
54
+ end
55
+
56
+ def offset?(token, limit)
57
+ token.offset < limit && token.type == :other && ["+", "-"].include?(token.value)
58
+ end
59
+
60
+ def check_calls(command)
61
+ tokens = document.tokens_for(command.logical_line)
62
+
63
+ tokens.each_index do |index|
64
+ if easter_of_today?(tokens, index)
65
+ offend_at(command.logical_line, tokens[index].offset, message(tokens[index]))
66
+ end
67
+ end
68
+ end
69
+
70
+ # `easterdate ( today ( ) )` -- the date form, fed the moving date.
71
+ def easter_of_today?(tokens, index)
72
+ window = tokens[index, 4]
73
+
74
+ window&.length == 4 &&
75
+ window[0].type == :function && FUNCTIONS.include?(window[0].value.downcase) &&
76
+ window[1].type == :lparen &&
77
+ window[2].type == :function && TODAY.include?(window[2].value.downcase) &&
78
+ window[3].type == :lparen
79
+ end
80
+
81
+ def message(token)
82
+ "`#{token.value}(today())` returns *next* Easter, so on the day itself it " \
83
+ "jumps a year ahead and the offset never triggers; pass the year instead, " \
84
+ "as `#{token.value}(year(today()))`"
85
+ end
86
+ end
87
+ end
88
+ end
89
+
90
+ __END__
91
+
92
+ require_relative "../document"
93
+
94
+ describe "RemLint::Rules::EasterdateFromToday" do
95
+ lint = proc do |text|
96
+ source = RemLint::Source.new(path: "t.rem", text: text)
97
+
98
+ RemLint::Rules::EasterdateFromToday.new.run(RemLint::Document.new(source))
99
+ end
100
+
101
+ messages = proc { |text| lint.(text).map(&:message) }
102
+
103
+ it "reports the date form with an offset" do
104
+ messages.("REM [easterdate(today())+1] MSG Easter Monday\n").first.should.match(
105
+ /jumps a year ahead and the offset never triggers/,
106
+ )
107
+ end
108
+
109
+ it "suggests the year form" do
110
+ messages.("REM [easterdate(today())+1] MSG hi\n").first.should.match(
111
+ /as `easterdate\(year\(today\(\)\)\)`/,
112
+ )
113
+ end
114
+
115
+ it "reports a negative offset too" do
116
+ lint.("REM [easterdate(today())-2] MSG Good Friday\n").length.should == 1
117
+ end
118
+
119
+ it "reports orthodoxeaster the same way" do
120
+ messages.("REM [orthodoxeaster(today())+1] MSG hi\n").first.should.match(/orthodoxeaster/)
121
+ end
122
+
123
+ it "accepts the date form with no offset" do
124
+ lint.("REM [easterdate(today())] MSG Easter Sunday\n").should.be.empty
125
+ end
126
+
127
+ it "accepts the year form, which does not move" do
128
+ lint.("REM [easterdate(year(today()))+1] MSG Easter Monday\n").should.be.empty
129
+ end
130
+
131
+ it "accepts an offset with no easterdate at all" do
132
+ lint.("REM [trigger(today())+1] MSG hi\n").should.be.empty
133
+ end
134
+
135
+ it "says nothing about an offset in the body" do
136
+ lint.("REM [easterdate(today())] MSG Easter, +1 day to go\n").should.be.empty
137
+ end
138
+
139
+ it "says nothing about a SET, which has no trigger" do
140
+ lint.("SET a easterdate(today())+1\n").should.be.empty
141
+ end
142
+
143
+ it "matches the function name case-insensitively" do
144
+ lint.("REM [EASTERDATE(TODAY())+1] MSG hi\n").length.should == 1
145
+ end
146
+
147
+ it "says nothing about comments" do
148
+ lint.("# REM [easterdate(today())+1] MSG hi\n").should.be.empty
149
+ end
150
+
151
+ it "points at the call" do
152
+ text = "REM [easterdate(today())+1] MSG hi\n"
153
+
154
+ lint.(text).first.column.should == text.index("easterdate") + 1
155
+ end
156
+
157
+ it "reports at warning severity" do
158
+ lint.("REM [easterdate(today())+1] MSG hi\n").first.severity.should == "warning"
159
+ end
160
+ end
@@ -0,0 +1,385 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+ require_relative "../vocabulary"
5
+
6
+ module RemLint
7
+ module Rules
8
+ # Calls that pass the wrong number of arguments.
9
+ #
10
+ # The arities come from Remind's own function table (src/funcs.c), so this
11
+ # is not a guess about what `ampm` accepts -- it is what `ampm` accepts.
12
+ # `defs.rem` defines fifteen-odd helpers and calls them throughout, and
13
+ # `astro` threads `sunrise($T+1)` through four separate heredocs; getting an
14
+ # argument count wrong there fails at trigger time, on the one day of the
15
+ # year the reminder fires.
16
+ #
17
+ # Two deliberate silences:
18
+ #
19
+ # UNKNOWN FUNCTIONS ARE NOT REPORTED. A reminder file's helpers usually
20
+ # arrive through `INCLUDE`, and this rule reads one file. Complaining about
21
+ # every call into `$SysInclude` would drown the calls it can actually check.
22
+ #
23
+ # A FUNCTION DEFINED LATER IN THE FILE STILL COUNTS. Definitions are
24
+ # collected in a first pass, because `FSET` at the bottom of a file is
25
+ # legal and common. A call is checked against the definition in force where
26
+ # it sits -- the nearest one above it -- and only falls back to a later
27
+ # definition when there is nothing above. Remind's own `tests/test.rem`
28
+ # defines `g(x, y)` on line 356 and redefines it as `g(x)` on line 1545;
29
+ # taking the last definition file-wide would report all three of the
30
+ # perfectly correct two-argument calls in between.
31
+ class FunctionArity < Rule
32
+ # `FSET name(a, b)` -- the parameter list ends at the first `)`, since
33
+ # Remind's parameters are plain names.
34
+ DEFINITION = /\A\s*(?<name>[A-Za-z_]\w*)\s*\((?<params>[^)]*)\)/
35
+
36
+ def self.default_severity
37
+ "error"
38
+ end
39
+
40
+ def self.description
41
+ "Calls that pass more or fewer arguments than the function takes."
42
+ end
43
+
44
+ def check
45
+ @definitions = collect_definitions
46
+
47
+ document.logical_lines.each_with_index do |logical_line, index|
48
+ command = document.commands[index]
49
+
50
+ if command.code?
51
+ check_calls(logical_line, command)
52
+ end
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ # Every `FSET` in the file, in order: [name, line, parameter count].
59
+ # Kept as a list rather than a hash because a file may define the same
60
+ # name twice and which one applies depends on where you are.
61
+ def collect_definitions
62
+ document.code_commands.filter_map do |command|
63
+ match = command.keyword?("FSET") && command.args.match(DEFINITION)
64
+
65
+ if match
66
+ [match[:name].downcase, command.line, count_parameters(match[:params])]
67
+ end
68
+ end
69
+ end
70
+
71
+ # The definition in force at `line`: the nearest one at or above it,
72
+ # or -- for a helper defined at the bottom of the file -- the first one
73
+ # below.
74
+ def definition_at(name, line)
75
+ candidates = @definitions.select { |defined_name, _line, _arity| defined_name == name }
76
+ above = candidates.select { |_name, defined_line, _arity| defined_line <= line }.last
77
+ chosen = above || candidates.first
78
+
79
+ chosen&.last
80
+ end
81
+
82
+ def count_parameters(params)
83
+ if params.strip.empty?
84
+ 0
85
+ else
86
+ params.split(",").length
87
+ end
88
+ end
89
+
90
+ def check_calls(logical_line, command)
91
+ tokens = document.tokens_for(logical_line)
92
+ from = body_offset(command)
93
+
94
+ tokens.each_with_index do |token, index|
95
+ if token.type == :function && token.offset >= from
96
+ check_call(logical_line, tokens, index)
97
+ end
98
+ end
99
+ end
100
+
101
+ # `FSET greet(a, b) ...` opens with something that lexes exactly like a
102
+ # call to `greet` -- it is the definition's parameter list. Skipping
103
+ # past it stops every definition reporting itself, which is what makes
104
+ # a redefinition (`FSET f(a)` then `FSET f(a, b)`) legal rather than an
105
+ # offence against its own later self.
106
+ def body_offset(command)
107
+ match = command.keyword?("FSET") && command.args.match(DEFINITION)
108
+
109
+ if match
110
+ command.args_offset + match.end(0)
111
+ else
112
+ 0
113
+ end
114
+ end
115
+
116
+ def check_call(logical_line, tokens, index)
117
+ token = tokens[index]
118
+ line, _column = logical_line.position_at(token.offset)
119
+ expected = arity_of(token.value, line)
120
+ actual = count_arguments(tokens, index + 1)
121
+
122
+ if !expected.nil? && !actual.nil? && !acceptable?(expected, actual)
123
+ offend_at(logical_line, token.offset, arity_message(token.value, expected, actual))
124
+ end
125
+ end
126
+
127
+ # A builtin's arity comes from Remind's table; a local `FSET` fixes an
128
+ # exact count. Anything else is out of scope and returns nil.
129
+ def arity_of(name, line)
130
+ builtin = Vocabulary.function(name)
131
+
132
+ if builtin
133
+ builtin
134
+ else
135
+ definition_at(name.downcase, line)
136
+ end
137
+ end
138
+
139
+ def acceptable?(expected, actual)
140
+ if expected.is_a?(Integer)
141
+ expected == actual
142
+ else
143
+ expected.accepts?(actual)
144
+ end
145
+ end
146
+
147
+ def arity_message(name, expected, actual)
148
+ if expected.is_a?(Integer)
149
+ wanted = expected.to_s
150
+ else
151
+ wanted = expected.arity_description
152
+ end
153
+
154
+ "`#{name}` takes #{wanted} #{pluralise(wanted)}, given #{actual}"
155
+ end
156
+
157
+ # "1" and "at least 1" are singular; "3" and "1 to 4" are not. The
158
+ # number the phrase ends on is the one being counted.
159
+ def pluralise(wanted)
160
+ if wanted[/\d+\z/] == "1"
161
+ "argument"
162
+ else
163
+ "arguments"
164
+ end
165
+ end
166
+
167
+ # Count top-level commas between the call's parentheses. Returns nil if
168
+ # the call is not parenthesised or never closes -- an unclosed call is
169
+ # UnbalancedDelimiters' offence, and guessing an argument count from a
170
+ # broken call would report a second, invented one.
171
+ def count_arguments(tokens, open_index)
172
+ open = tokens[open_index]
173
+
174
+ if open.nil? || open.type != :lparen
175
+ nil
176
+ else
177
+ walk_arguments(tokens, open_index)
178
+ end
179
+ end
180
+
181
+ def walk_arguments(tokens, open_index)
182
+ depth = 0
183
+ commas = 0
184
+ empty = true
185
+ closed = false
186
+ cursor = open_index
187
+
188
+ while cursor < tokens.length && !closed
189
+ token = tokens[cursor]
190
+ depth, commas, empty, closed = step(
191
+ token,
192
+ depth,
193
+ commas,
194
+ empty,
195
+ closed,
196
+ )
197
+ cursor += 1
198
+ end
199
+
200
+ arguments(closed, commas, empty)
201
+ end
202
+
203
+ def step(token, depth, commas, empty, closed)
204
+ case token.type
205
+ when :lparen, :lbracket
206
+ [depth + 1, commas, empty, closed]
207
+ when :rparen, :rbracket
208
+ close_or_descend(
209
+ token,
210
+ depth,
211
+ commas,
212
+ empty,
213
+ )
214
+ when :comma
215
+ [depth, depth == 1 ? commas + 1 : commas, empty, closed]
216
+ else
217
+ [depth, commas, depth == 1 ? false : empty, closed]
218
+ end
219
+ end
220
+
221
+ def close_or_descend(_token, depth, commas, empty)
222
+ if depth == 1
223
+ [depth - 1, commas, empty, true]
224
+ else
225
+ [depth - 1, commas, empty, false]
226
+ end
227
+ end
228
+
229
+ # `f()` is zero arguments; `f(a, b)` is commas plus one. A call that
230
+ # never closed yields nil so nothing is reported for it.
231
+ def arguments(closed, commas, empty)
232
+ if !closed
233
+ nil
234
+ elsif empty && commas.zero?
235
+ 0
236
+ else
237
+ commas + 1
238
+ end
239
+ end
240
+ end
241
+ end
242
+ end
243
+
244
+ __END__
245
+
246
+ require_relative "../document"
247
+
248
+ describe "RemLint::Rules::FunctionArity" do
249
+ lint = proc do |text|
250
+ source = RemLint::Source.new(path: "t.rem", text: text)
251
+
252
+ RemLint::Rules::FunctionArity.new.run(RemLint::Document.new(source))
253
+ end
254
+
255
+ messages = proc { |text| lint.(text).map(&:message) }
256
+
257
+ describe "builtin functions" do
258
+ it "accepts a call with the right number of arguments" do
259
+ lint.("MSG [abs(-1)]\n").should.be.empty
260
+ end
261
+
262
+ it "reports too many arguments" do
263
+ messages.("MSG [abs(1, 2)]\n").should == ["`abs` takes 1 argument, given 2"]
264
+ end
265
+
266
+ it "reports too few arguments" do
267
+ messages.("MSG [date(2026, 1)]\n").should == ["`date` takes 3 arguments, given 2"]
268
+ end
269
+
270
+ it "accepts anything inside an optional range" do
271
+ lint.("MSG [ampm(1)] [ampm(1,2)] [ampm(1,2,3,4)]\n").should.be.empty
272
+ end
273
+
274
+ it "reports past the top of an optional range" do
275
+ messages.("MSG [ampm(1,2,3,4,5)]\n").should == ["`ampm` takes 1 to 4 arguments, given 5"]
276
+ end
277
+
278
+ it "accepts any count for a variadic function" do
279
+ lint.("MSG [char(65, 66, 67, 68)]\n").should.be.empty
280
+ end
281
+
282
+ it "reports below a variadic function's minimum" do
283
+ messages.("MSG [char()]\n").should == ["`char` takes at least 1 argument, given 0"]
284
+ end
285
+
286
+ it "accepts a zero-argument call" do
287
+ lint.("MSG [version()] [today()]\n").should.be.empty
288
+ end
289
+
290
+ it "reports arguments given to a function that takes none" do
291
+ messages.("MSG [version(1)]\n").should == ["`version` takes 0 arguments, given 1"]
292
+ end
293
+
294
+ it "matches the function name case-insensitively, as Remind does" do
295
+ messages.("MSG [ABS(1, 2)]\n").should == ["`ABS` takes 1 argument, given 2"]
296
+ end
297
+ end
298
+
299
+ describe "nested calls" do
300
+ it "counts arguments at the right depth" do
301
+ lint.("MSG [max(abs(-1), abs(-2))]\n").should.be.empty
302
+ end
303
+
304
+ it "reports the inner call, not the outer one" do
305
+ messages.("MSG [max(abs(-1, 9), 2)]\n").should == ["`abs` takes 1 argument, given 2"]
306
+ end
307
+
308
+ it "does not count a comma inside a nested call as its own argument" do
309
+ lint.("MSG [ansicolor(0, 255, 0) + center(\"x\")]\n").should.be.empty
310
+ end
311
+
312
+ it "does not count a comma inside a string as an argument separator" do
313
+ lint.(%(MSG [abs("a, b, c")]\n)).should.be.empty
314
+ end
315
+ end
316
+
317
+ describe "functions the file defines itself" do
318
+ it "checks a call against its FSET" do
319
+ messages.("FSET greet(a, b) a + b\nMSG [greet(1)]\n").should ==
320
+ ["`greet` takes 2 arguments, given 1"]
321
+ end
322
+
323
+ it "accepts a correct call" do
324
+ lint.("FSET greet(a, b) a + b\nMSG [greet(1, 2)]\n").should.be.empty
325
+ end
326
+
327
+ it "accepts a definition that appears after the call" do
328
+ lint.("MSG [greet(1, 2)]\nFSET greet(a, b) a + b\n").should.be.empty
329
+ end
330
+
331
+ it "handles a nullary definition" do
332
+ messages.("FSET now() today()\nMSG [now(1)]\n").should == ["`now` takes 0 arguments, given 1"]
333
+ end
334
+
335
+ it "does not treat the definition's own parameter list as a call" do
336
+ lint.("FSET center(x) pad(\"\", \" \", (columns() - columns(x))/2) + x\n").should.be.empty
337
+ end
338
+
339
+ it "checks a call against the definition in force above it" do
340
+ # Remind's own tests/test.rem does exactly this: g(x, y) high up, g(x)
341
+ # much further down, with correct two-argument calls in between.
342
+ text = "FSET g(a, b) a + b\nMSG [g(1, 2)]\nFSET g(a) a\nMSG [g(1)]\n"
343
+
344
+ lint.(text).should.be.empty
345
+ end
346
+
347
+ it "reports a call that matches only the other definition" do
348
+ text = "FSET g(a, b) a + b\nMSG [g(1)]\nFSET g(a) a\n"
349
+
350
+ messages.(text).should == ["`g` takes 2 arguments, given 1"]
351
+ end
352
+ end
353
+
354
+ describe "what it stays quiet about" do
355
+ it "says nothing about a function it has never heard of" do
356
+ # Helpers usually arrive through INCLUDE, which this rule cannot see.
357
+ lint.("MSG [helper_from_include(1, 2, 3)]\n").should.be.empty
358
+ end
359
+
360
+ it "says nothing about a bare name that is not a call" do
361
+ lint.("MSG [abs]\n").should.be.empty
362
+ end
363
+
364
+ it "says nothing about a call that never closes" do
365
+ # That is UnbalancedDelimiters' offence; counting arguments in a broken
366
+ # call would invent a second one.
367
+ lint.("MSG [abs(1, 2\n").should.be.empty
368
+ end
369
+
370
+ it "says nothing about comments" do
371
+ lint.("# call abs(1, 2) somewhere\n").should.be.empty
372
+ end
373
+ end
374
+
375
+ it "reports the physical line a call sits on inside a continuation" do
376
+ offense = lint.("SET x 1 + \\\n abs(1, 2)\n").first
377
+
378
+ offense.line.should == 2
379
+ offense.column.should == 5
380
+ end
381
+
382
+ it "reports at error severity" do
383
+ lint.("MSG [abs(1, 2)]\n").first.severity.should == "error"
384
+ end
385
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+ require_relative "../vocabulary"
5
+
6
+ module RemLint
7
+ module Rules
8
+ # A function defined twice without saying so.
9
+ #
10
+ # Remind warns about a redefinition itself, and gives you a way to say the
11
+ # redefinition is deliberate: `FSET - name(...)` sets
12
+ # `suppress_redefined_function_warning` and the warning goes away
13
+ # (src/userfns.c). So this rule is not telling you something Remind would
14
+ # not -- it is telling you where the `-` goes.
15
+ #
16
+ # Worth having anyway, because the two definitions are usually a long way
17
+ # apart. Remind's own `tests/test.rem` defines `g(x, y)` on line 356 and
18
+ # redefines it as `g(x)` on line 1545, and every call in between is
19
+ # checked against a different function than the one the reader is looking
20
+ # at.
21
+ #
22
+ # A definition inside a `PUSH-FUNCS` block is left alone: pushing the
23
+ # function table is exactly how you say "this redefinition is scoped", and
24
+ # Remind skips its own warning for a pushed function too
25
+ # (`!existing->been_pushed`).
26
+ class FunctionRedefinition < Rule
27
+ # `FSET name(args)` and the `FSET - name(args)` that suppresses the
28
+ # warning.
29
+ DEFINITION = /\A(?<deliberate>-\s*)?(?<name>[A-Za-z_]\w*)\s*\(/
30
+
31
+ def self.default_severity
32
+ "warning"
33
+ end
34
+
35
+ def self.description
36
+ "A function redefined without the FSET - form that says so."
37
+ end
38
+
39
+ def check
40
+ seen = {}
41
+ pushed = 0
42
+
43
+ document.code_commands.each do |command|
44
+ pushed = track(command, pushed)
45
+
46
+ if command.keyword?("FSET") && pushed.zero?
47
+ check_definition(command, seen)
48
+ end
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def track(command, pushed)
55
+ if command.keyword?("PUSH-FUNCS")
56
+ pushed + 1
57
+ elsif command.keyword?("POP-FUNCS")
58
+ [pushed - 1, 0].max
59
+ else
60
+ pushed
61
+ end
62
+ end
63
+
64
+ def check_definition(command, seen)
65
+ match = command.args.match(DEFINITION)
66
+
67
+ if match
68
+ record(command, match, seen)
69
+ end
70
+ end
71
+
72
+ def record(command, match, seen)
73
+ name = match[:name].downcase
74
+ first = seen[name]
75
+
76
+ if first && !match[:deliberate]
77
+ offend(command.line, message(match[:name], first), column: command.keyword_column)
78
+ end
79
+
80
+ seen[name] ||= command.line
81
+ end
82
+
83
+ def message(name, first)
84
+ "`#{name}` was already defined on line #{first}; write `FSET - #{name}(...)` " \
85
+ "to say the redefinition is deliberate and silence Remind's warning"
86
+ end
87
+ end
88
+ end
89
+ end
90
+
91
+ __END__
92
+
93
+ require_relative "../document"
94
+
95
+ describe "RemLint::Rules::FunctionRedefinition" do
96
+ lint = proc do |text|
97
+ source = RemLint::Source.new(path: "t.rem", text: text)
98
+
99
+ RemLint::Rules::FunctionRedefinition.new.run(RemLint::Document.new(source))
100
+ end
101
+
102
+ messages = proc { |text| lint.(text).map(&:message) }
103
+
104
+ it "reports a second definition of the same name" do
105
+ messages.("FSET g(a, b) a + b\nFSET g(a) a\n").first.should ==
106
+ "`g` was already defined on line 1; write `FSET - g(...)` to say the redefinition " \
107
+ "is deliberate and silence Remind's warning"
108
+ end
109
+
110
+ it "reports the second and not the first" do
111
+ lint.("FSET g(a) a\nFSET g(b) b\n").map(&:line).should == [2]
112
+ end
113
+
114
+ it "reports each redefinition after the first" do
115
+ lint.("FSET g(a) a\nFSET g(b) b\nFSET g(c) c\n").map(&:line).should == [2, 3]
116
+ end
117
+
118
+ it "accepts the FSET - form that says it is deliberate" do
119
+ lint.("FSET g(a) a\nFSET - g(b) b\n").should.be.empty
120
+ end
121
+
122
+ it "accepts the deliberate form written without a space" do
123
+ lint.("FSET g(a) a\nFSET -g(b) b\n").should.be.empty
124
+ end
125
+
126
+ it "matches the name case-insensitively, as Remind does" do
127
+ lint.("FSET g(a) a\nFSET G(b) b\n").length.should == 1
128
+ end
129
+
130
+ it "accepts two different functions" do
131
+ lint.("FSET g(a) a\nFSET h(b) b\n").should.be.empty
132
+ end
133
+
134
+ it "accepts a redefinition inside a PUSH-FUNCS block" do
135
+ # Pushing the table is how a scoped redefinition is said, and Remind
136
+ # skips its own warning for a pushed function too.
137
+ lint.("FSET g(a) a\nPUSH-FUNCS\nFSET g(b) b\nPOP-FUNCS\n").should.be.empty
138
+ end
139
+
140
+ it "resumes checking after the block closes" do
141
+ lint.("FSET g(a) a\nPUSH-FUNCS\nPOP-FUNCS\nFSET g(b) b\n").length.should == 1
142
+ end
143
+
144
+ it "points at the keyword" do
145
+ lint.("FSET g(a) a\n FSET g(b) b\n").first.column.should == 4
146
+ end
147
+
148
+ it "says nothing about comments" do
149
+ lint.("FSET g(a) a\n# FSET g(b) b\n").should.be.empty
150
+ end
151
+
152
+ it "says nothing about a call that looks like a definition" do
153
+ lint.("FSET g(a) a\nMSG [g(1)]\n").should.be.empty
154
+ end
155
+
156
+ it "reports at warning severity" do
157
+ lint.("FSET g(a) a\nFSET g(b) b\n").first.severity.should == "warning"
158
+ end
159
+ end