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,359 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "expr_lexer"
4
+ require_relative "vocabulary"
5
+
6
+ module RemLint
7
+ # The clauses of one command's trigger, and where its body starts.
8
+ #
9
+ # Everything before the reminder type -- `MSG`, `MSF`, `RUN`, `CAL`,
10
+ # `SATISFY`, `SPECIAL`, `PS`, `PSFILE` -- is the trigger. Everything after is
11
+ # the body, and the body is *text*.
12
+ #
13
+ # That boundary is the whole point of this class. `REM Tue AT 15:00 MSG Meet
14
+ # Bob at the pub` contains the word `at` twice: once as the clause that sets
15
+ # the time, once as an ordinary English preposition. A rule that grepped the
16
+ # line for `AT` would find both. Scanning stops at the body keyword, so only
17
+ # the first one is a clause.
18
+ #
19
+ # Hyphenated clause keywords (`MAX-OVERDUE`, `COMPLETE-THROUGH`) lex as three
20
+ # tokens, so a name followed by a hyphen and another name is retried as one
21
+ # word before being taken as two.
22
+ class Trigger
23
+ Clause = Struct.new(
24
+ :keyword,
25
+ :offset,
26
+ :end_offset,
27
+ :token_index,
28
+ :token_width,
29
+ keyword_init: true,
30
+ ) do
31
+ def name
32
+ keyword.name
33
+ end
34
+
35
+ # Where the clause's argument starts, in tokens. A hyphenated keyword
36
+ # such as `MAX-OVERDUE` occupies three of them, so this is not always
37
+ # `token_index + 1`.
38
+ def argument_index
39
+ token_index + token_width
40
+ end
41
+ end
42
+
43
+ # The commands that open a trigger. Everything else -- `ERRMSG`, `SET`,
44
+ # `BANNER`, `INCLUDE` -- takes its own argument, and scanning that argument
45
+ # for a reminder type finds words rather than keywords. Remind's own
46
+ # `tests/tstlang.rem` has
47
+ #
48
+ # errmsg Please run [filename()] with the -q and -r options
49
+ #
50
+ # where `run` is English. Treating it as the start of a RUN body makes the
51
+ # rest of the sentence look like a shell command.
52
+ TRIGGER_COMMANDS = %w[REM OMIT IFTRIG].freeze
53
+
54
+ attr_reader :clauses, :body
55
+
56
+ def initialize(tokens, triggered: true)
57
+ @tokens = tokens
58
+ @clauses = []
59
+ @body = nil
60
+ @triggered = triggered
61
+
62
+ if triggered
63
+ scan
64
+ end
65
+ end
66
+
67
+ # Whether this command carries a trigger at all. Rules that look for
68
+ # something positional -- a repeat, a delta -- need it, because `SET a 3*14`
69
+ # has a `*` in it and no trigger for that `*` to be part of.
70
+ def triggered?
71
+ @triggered
72
+ end
73
+
74
+ def self.of(tokens, triggered: true)
75
+ new(tokens, triggered: triggered)
76
+ end
77
+
78
+ # Whether this command carries a trigger at all: the three commands that
79
+ # open one, a bare reminder type (`MSG hello`), or a trigger written with
80
+ # the `REM` left off.
81
+ def self.triggered?(command)
82
+ command.implicit? ||
83
+ command.keyword?(*TRIGGER_COMMANDS) ||
84
+ !!command.keyword&.body?
85
+ end
86
+
87
+ # The clause of that name, or nil. Names are canonical, so `find("UNTIL")`
88
+ # matches an abbreviated `UNTIL` written as `UNTI`.
89
+ def find(name)
90
+ clauses.find { |clause| clause.name == name.upcase }
91
+ end
92
+
93
+ def include?(*names)
94
+ wanted = names.map(&:upcase)
95
+
96
+ clauses.any? { |clause| wanted.include?(clause.name) }
97
+ end
98
+
99
+ def body?
100
+ !body.nil?
101
+ end
102
+
103
+ # A body Remind runs the substitution filter over: everything but SATISFY.
104
+ #
105
+ # SATISFY's body is an *expression*, and in an expression `%` is the modulo
106
+ # operator (`expr.c:1473`). `SATISFY [($Ty % 4) == 0]` in Remind's own
107
+ # `examples/defs.rem` is arithmetic, not a malformed substitution, and a
108
+ # rule that scanned it for `%` sequences would say so on every leap-year
109
+ # test ever written.
110
+ EXPRESSION_BODIES = %w[SATISFY].freeze
111
+
112
+ def text_body?
113
+ body? && !EXPRESSION_BODIES.include?(body.name)
114
+ end
115
+
116
+ # The tokens after the reminder type, which are the body's text. Empty for
117
+ # a command with no body keyword at all.
118
+ def body_tokens
119
+ if body
120
+ @tokens[(body.token_index + 1)..] || []
121
+ else
122
+ []
123
+ end
124
+ end
125
+
126
+ # Where the body's text begins in the logical line, or nil.
127
+ def body_offset
128
+ body&.end_offset
129
+ end
130
+
131
+ private
132
+
133
+ def scan
134
+ index = 0
135
+
136
+ while index < @tokens.length && body.nil?
137
+ index += consume(index)
138
+ end
139
+ end
140
+
141
+ # Returns how many tokens were taken, so a hyphenated keyword advances
142
+ # past all three of its tokens rather than rediscovering its own tail.
143
+ def consume(index)
144
+ token = @tokens[index]
145
+
146
+ if token.type != :name
147
+ 1
148
+ else
149
+ classify(index, token)
150
+ end
151
+ end
152
+
153
+ def classify(index, token)
154
+ joined = hyphenated(index)
155
+ keyword = Vocabulary.keyword(joined || token.value)
156
+ if joined
157
+ width = 3
158
+ else
159
+ width = 1
160
+ end
161
+
162
+ if keyword.nil?
163
+ 1
164
+ else
165
+ record(
166
+ keyword,
167
+ token,
168
+ index,
169
+ width,
170
+ )
171
+ width
172
+ end
173
+ end
174
+
175
+ # `MAX-OVERDUE` arrives as name, `-`, name. Only accepted when the joined
176
+ # form actually resolves, so an ordinary `a-b` stays two names.
177
+ def hyphenated(index)
178
+ hyphen = @tokens[index + 1]
179
+ tail = @tokens[index + 2]
180
+
181
+ if hyphen&.type == :other && hyphen.value == "-" && tail&.type == :name
182
+ candidate = "#{@tokens[index].value}-#{tail.value}"
183
+
184
+ if Vocabulary.keyword(candidate)&.name&.include?("-")
185
+ candidate
186
+ end
187
+ end
188
+ end
189
+
190
+ def record(keyword, token, index, width)
191
+ last = @tokens[index + width - 1]
192
+
193
+ clause = Clause.new(
194
+ keyword: keyword,
195
+ offset: token.offset,
196
+ end_offset: last.end_offset,
197
+ token_index: index,
198
+ token_width: width,
199
+ )
200
+
201
+ if keyword.body?
202
+ @body = clause
203
+ else
204
+ @clauses << clause
205
+ end
206
+ end
207
+ end
208
+ end
209
+
210
+ __END__
211
+
212
+ require_relative "document"
213
+
214
+ describe "RemLint::Trigger" do
215
+ trigger = proc do |text|
216
+ source = RemLint::Source.new(path: "t.rem", text: text)
217
+ document = RemLint::Document.new(source)
218
+ line = document.logical_lines.first
219
+
220
+ RemLint::Trigger.of(document.tokens_for(line))
221
+ end
222
+
223
+ names = proc { |text| trigger.(text).clauses.map(&:name) }
224
+
225
+ describe "finding clauses" do
226
+ it "lists the clauses of a trigger, and not the reminder type" do
227
+ # MSG ends the trigger, so it is the body rather than a clause.
228
+ names.("REM Tuesday AT 15:00 MSG Staff Meeting").should == %w[REM TUESDAY AT]
229
+ end
230
+
231
+ it "answers include? by canonical name" do
232
+ trigger.("REM 1 Jan UNTIL 2027-01-01 MSG hi").include?("UNTIL").should.be.true
233
+ trigger.("REM 1 Jan MSG hi").include?("UNTIL").should.be.false
234
+ end
235
+
236
+ it "matches an abbreviated keyword" do
237
+ trigger.("REM 1 Jan SCAN -7 MSG hi").include?("SCANFROM").should.be.true
238
+ end
239
+
240
+ it "is case-insensitive" do
241
+ trigger.("rem tuesday at 15:00 msg hi").include?("AT").should.be.true
242
+ end
243
+ end
244
+
245
+ describe "the trigger/body boundary" do
246
+ it "stops scanning at the reminder type" do
247
+ # The second `at` is English, not a clause.
248
+ trigger.("REM Tue AT 15:00 MSG Meet Bob at the pub").clauses.count { |c|
249
+ c.name == "AT"
250
+ }.should == 1
251
+ end
252
+
253
+ it "does not find a clause keyword that only appears in the body" do
254
+ trigger.("REM 1 Jan MSG Ask about the duration until Friday").include?("DURATION").should.be.false
255
+ trigger.("REM 1 Jan MSG Ask about the duration until Friday").include?("UNTIL").should.be.false
256
+ end
257
+
258
+ it "records which reminder type ended the trigger" do
259
+ trigger.("REM 1 Jan MSG hi").body.name.should == "MSG"
260
+ trigger.("REM 1 Jan RUN echo hi").body.name.should == "RUN"
261
+ trigger.("REM 1 Jan SATISFY [1]").body.name.should == "SATISFY"
262
+ end
263
+
264
+ it "reports no body for a command that has none" do
265
+ trigger.("OMIT 1 January").should.not.be.body
266
+ trigger.("OMIT 1 January").body_tokens.should.be.empty
267
+ end
268
+
269
+ it "gives the offset where the body's text starts" do
270
+ command = "REM 1 Jan MSG hello"
271
+
272
+ trigger.(command).body_offset.should == command.index("MSG") + 3
273
+ end
274
+ end
275
+
276
+ describe "hyphenated clause keywords" do
277
+ it "resolves them as one keyword" do
278
+ trigger.("REM 1 Jan MAX-OVERDUE 5 MSG hi").include?("MAX-OVERDUE").should.be.true
279
+ end
280
+
281
+ it "reports the argument index past all three tokens" do
282
+ found = trigger.("REM 1 Jan MAX-OVERDUE 5 MSG hi").find("MAX-OVERDUE")
283
+
284
+ found.token_width.should == 3
285
+ found.argument_index.should == found.token_index + 3
286
+ end
287
+
288
+ it "does not split one into its halves" do
289
+ names.("REM 1 Jan MAX-OVERDUE 5 MSG hi").should.not.include "MAY"
290
+ end
291
+
292
+ it "leaves a hyphen alone when the joined form is not a keyword" do
293
+ # MAY is a month; MAY-BE is nothing, so the hyphen must not swallow it.
294
+ names.("REM MAY-BE 5 MSG hi").should == %w[REM MAY]
295
+ end
296
+ end
297
+
298
+ describe "commands that carry no trigger" do
299
+ triggered = proc do |text|
300
+ source = RemLint::Source.new(path: "t.rem", text: text)
301
+ document = RemLint::Document.new(source)
302
+
303
+ RemLint::Trigger.triggered?(document.code_commands.first)
304
+ end
305
+
306
+ it "counts REM, OMIT and IFTRIG" do
307
+ triggered.("REM 1 Jan MSG hi").should.be.true
308
+ triggered.("OMIT 1 Jan MSG hi").should.be.true
309
+ triggered.("IFTRIG 1 Jan").should.be.true
310
+ end
311
+
312
+ it "counts a bare reminder type and an implicit trigger" do
313
+ triggered.("MSG hello").should.be.true
314
+ triggered.("1 Nov ++12 MSG hi").should.be.true
315
+ end
316
+
317
+ it "does not count commands that take their own argument" do
318
+ triggered.("ERRMSG Please run [filename()] with -q").should.be.false
319
+ triggered.("SET a 1").should.be.false
320
+ triggered.("BANNER %").should.be.false
321
+ triggered.("INCLUDE defs.rem").should.be.false
322
+ end
323
+
324
+ it "finds nothing at all when told the command has no trigger" do
325
+ source = RemLint::Source.new(path: "t.rem", text: "ERRMSG Please run [x] now")
326
+ document = RemLint::Document.new(source)
327
+ line = document.logical_lines.first
328
+ bare = RemLint::Trigger.of(document.tokens_for(line), triggered: false)
329
+
330
+ bare.body.should.be.nil
331
+ bare.clauses.should.be.empty
332
+ end
333
+ end
334
+
335
+ describe "text bodies versus expression bodies" do
336
+ it "counts MSG, RUN and the rest as text" do
337
+ trigger.("REM 1 Jan MSG hi").should.be.text_body
338
+ trigger.("REM 1 Jan RUN cmd").should.be.text_body
339
+ trigger.("REM 1 Jan CAL hi").should.be.text_body
340
+ end
341
+
342
+ it "does not count SATISFY, whose body is an expression" do
343
+ # `%` there is modulo, not a substitution.
344
+ trigger.("REM Tue Nov 2 SATISFY [($Ty % 4) == 0]").text_body?.should.be.false
345
+ end
346
+
347
+ it "does not count a command with no body at all" do
348
+ trigger.("OMIT 1 January").text_body?.should.be.false
349
+ end
350
+ end
351
+
352
+ it "handles a trigger written with no REM at all" do
353
+ trigger.("1 Nov ++12 MSG Get ready").body.name.should == "MSG"
354
+ end
355
+
356
+ it "handles a comment without finding clauses in it" do
357
+ trigger.("# until the AT clause").body.should.be.nil
358
+ end
359
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RemLint
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tables"
4
+
5
+ module RemLint
6
+ # Lookups over the generated tables, reproducing Remind's own resolution
7
+ # rules rather than approximating them.
8
+ #
9
+ # The three rules differ from each other, and each difference is a bug class
10
+ # if you get it wrong:
11
+ #
12
+ # keywords case-insensitive, abbreviable to a per-keyword minimum length
13
+ # (src/token.c FindToken)
14
+ # functions case-insensitive, exact -- no abbreviation
15
+ # (src/funcs.c FindBuiltinFunc)
16
+ # $SysVars case-insensitive, exact -- no abbreviation
17
+ # (src/var.c FindSysVar)
18
+ module Vocabulary
19
+ # The clause keywords -- UNTIL, AT, WARN and so on -- appear inside a
20
+ # trigger rather than at the head of a line, so a rule looking at the first
21
+ # word of a command needs to tell the two apart.
22
+ CLAUSE_TYPES = %w[
23
+ T_Month T_WkDay T_Ordinal T_Skip T_At T_Duration T_Until T_Warn
24
+ T_Sched T_Scanfrom T_Through T_Once T_Priority T_Tag T_In T_Tz
25
+ T_BackAdj T_Info T_NoQueue T_MaxOverdue T_CompleteThrough
26
+ T_MaybeUncomputable T_OmitFunc
27
+ ].freeze
28
+
29
+ Keyword = Struct.new(:name, :minlen, :type) do
30
+ def clause?
31
+ CLAUSE_TYPES.include?(type)
32
+ end
33
+
34
+ # `MSG`, `MSF`, `RUN`, `CAL`, `SATISFY`, `SPECIAL`, `PS`, `PSFILE` --
35
+ # the token that ends a trigger and starts the reminder body.
36
+ def body?
37
+ type == "T_RemType"
38
+ end
39
+ end
40
+
41
+ Function = Struct.new(:name, :min, :max) do
42
+ def variadic?
43
+ max == Tables::NO_MAX_ARGS
44
+ end
45
+
46
+ def accepts?(count)
47
+ count >= min && (variadic? || count <= max)
48
+ end
49
+
50
+ # "1", "2 to 5", "at least 2" -- the phrasing an arity message needs.
51
+ def arity_description
52
+ if variadic?
53
+ "at least #{min}"
54
+ elsif min == max
55
+ min.to_s
56
+ else
57
+ "#{min} to #{max}"
58
+ end
59
+ end
60
+ end
61
+
62
+ SysVar = Struct.new(
63
+ :name,
64
+ :modifiable,
65
+ :type,
66
+ :min,
67
+ :max,
68
+ ) do
69
+ def bounded?
70
+ !min.nil? && !max.nil?
71
+ end
72
+
73
+ def in_range?(value)
74
+ value >= min && value <= max
75
+ end
76
+ end
77
+
78
+ KEYWORDS = Tables::KEYWORD_ROWS.map { |row| Keyword.new(*row).freeze }.freeze
79
+
80
+ FUNCTIONS = Tables::FUNCTION_ROWS.to_h do |row|
81
+ function = Function.new(*row).freeze
82
+
83
+ [function.name, function]
84
+ end.freeze
85
+
86
+ SYSVARS = Tables::SYSVAR_ROWS.to_h do |row|
87
+ sysvar = SysVar.new(*row).freeze
88
+
89
+ [sysvar.name.downcase, sysvar]
90
+ end.freeze
91
+
92
+ # Resolved lookups, because the linter asks the same questions about the
93
+ # same words over and over: every `:name` token in every trigger goes
94
+ # through `keyword`, and a linear walk of 91 entries per token is the
95
+ # difference between 3.3 and 1.9 seconds per megabyte. Bounded by the
96
+ # distinct words in the files being linted.
97
+ #
98
+ # Guarded like Rule::REGISTRY: `scampi` re-executes each file's top level
99
+ # to reach its specs, and an unguarded literal would drop the cache
100
+ # mid-run.
101
+ unless defined?(KEYWORD_CACHE)
102
+ KEYWORD_CACHE = {}
103
+ end
104
+
105
+ module_function
106
+
107
+ # Resolve one whitespace-delimited word the way FindToken does: the first
108
+ # entry in table order whose name the word prefixes, provided the word is
109
+ # at least that entry's minimum abbreviation length. A trailing comma is
110
+ # ignored, as Remind's TokStrCmp ignores it.
111
+ def keyword(word)
112
+ needle = word.to_s.downcase.delete_suffix(",")
113
+
114
+ if needle.empty?
115
+ nil
116
+ else
117
+ KEYWORD_CACHE.fetch(needle) { KEYWORD_CACHE[needle] = resolve(needle) }
118
+ end
119
+ end
120
+
121
+ def resolve(needle)
122
+ KEYWORDS.find do |candidate|
123
+ needle.length >= candidate.minlen && candidate.name.downcase.start_with?(needle)
124
+ end
125
+ end
126
+
127
+ def function(name)
128
+ FUNCTIONS[name.to_s.downcase]
129
+ end
130
+
131
+ def sysvar(name)
132
+ SYSVARS[name.to_s.delete_prefix("$").downcase]
133
+ end
134
+ end
135
+ end
136
+
137
+ __END__
138
+
139
+ describe "RemLint::Vocabulary.keyword" do
140
+ it "resolves a full keyword regardless of case" do
141
+ RemLint::Vocabulary.keyword("MSG").name.should == "MSG"
142
+ RemLint::Vocabulary.keyword("msg").name.should == "MSG"
143
+ RemLint::Vocabulary.keyword("MsG").name.should == "MSG"
144
+ end
145
+
146
+ it "accepts an abbreviation at or past the minimum length" do
147
+ # `include` has MinLen 3, which is what makes INC an INCLUDE.
148
+ RemLint::Vocabulary.keyword("inc").name.should == "INCLUDE"
149
+ RemLint::Vocabulary.keyword("inclu").name.should == "INCLUDE"
150
+ end
151
+
152
+ it "rejects an abbreviation shorter than the minimum length" do
153
+ # `omit` has MinLen 4, so `omi` is not a keyword at all.
154
+ RemLint::Vocabulary.keyword("omi").should.be.nil
155
+ end
156
+
157
+ it "resolves the hyphenated context keywords" do
158
+ RemLint::Vocabulary.keyword("PUSH-OMIT-CONTEXT").name.should == "PUSH-OMIT-CONTEXT"
159
+ RemLint::Vocabulary.keyword("PUSH").name.should == "PUSH-OMIT-CONTEXT"
160
+ RemLint::Vocabulary.keyword("POP").name.should == "POP-OMIT-CONTEXT"
161
+ end
162
+
163
+ it "ignores a trailing comma, as TokStrCmp does" do
164
+ RemLint::Vocabulary.keyword("mon,").name.should == "MONDAY"
165
+ end
166
+
167
+ it "caches a resolved word without changing the answer" do
168
+ RemLint::Vocabulary.keyword("msg").name.should == "MSG"
169
+ RemLint::Vocabulary.keyword("msg").name.should == "MSG"
170
+ RemLint::Vocabulary.keyword("frobnicate").should.be.nil
171
+ RemLint::Vocabulary.keyword("frobnicate").should.be.nil
172
+ end
173
+
174
+ it "returns nil for a word that is not a keyword" do
175
+ RemLint::Vocabulary.keyword("frobnicate").should.be.nil
176
+ RemLint::Vocabulary.keyword("").should.be.nil
177
+ end
178
+
179
+ it "distinguishes clause keywords from body keywords" do
180
+ RemLint::Vocabulary.keyword("UNTIL").should.be.clause
181
+ RemLint::Vocabulary.keyword("MSG").should.be.body
182
+ RemLint::Vocabulary.keyword("MSG").clause?.should.be.false
183
+ end
184
+ end
185
+
186
+ describe "RemLint::Vocabulary.function" do
187
+ it "looks up builtins case-insensitively and exactly" do
188
+ RemLint::Vocabulary.function("trigger").name.should == "trigger"
189
+ RemLint::Vocabulary.function("TRIGGER").name.should == "trigger"
190
+ RemLint::Vocabulary.function("trigg").should.be.nil
191
+ end
192
+
193
+ it "reports arity from Remind's own table" do
194
+ RemLint::Vocabulary.function("abs").accepts?(1).should.be.true
195
+ RemLint::Vocabulary.function("abs").accepts?(2).should.be.false
196
+ RemLint::Vocabulary.function("abs").arity_description.should == "1"
197
+ end
198
+
199
+ it "reports a range for optional arguments" do
200
+ ampm = RemLint::Vocabulary.function("ampm")
201
+ ampm.accepts?(1).should.be.true
202
+ ampm.accepts?(4).should.be.true
203
+ ampm.accepts?(5).should.be.false
204
+ ampm.arity_description.should == "1 to 4"
205
+ end
206
+
207
+ it "treats NO_MAX_ARGS as variadic" do
208
+ char = RemLint::Vocabulary.function("char")
209
+ char.should.be.variadic
210
+ char.accepts?(99).should.be.true
211
+ char.accepts?(0).should.be.false
212
+ char.arity_description.should == "at least 1"
213
+ end
214
+ end
215
+
216
+ describe "RemLint::Vocabulary.sysvar" do
217
+ it "looks up system variables case-insensitively, with or without the sigil" do
218
+ RemLint::Vocabulary.sysvar("$FormWidth").name.should == "FormWidth"
219
+ RemLint::Vocabulary.sysvar("formwidth").name.should == "FormWidth"
220
+ RemLint::Vocabulary.sysvar("$NoSuchVariable").should.be.nil
221
+ end
222
+
223
+ it "knows which variables reject SET" do
224
+ RemLint::Vocabulary.sysvar("$CalMode").modifiable.should.be.false
225
+ RemLint::Vocabulary.sysvar("$FormWidth").modifiable.should.be.true
226
+ end
227
+
228
+ it "carries the range only for the integer variables Remind bounds" do
229
+ form_width = RemLint::Vocabulary.sysvar("$FormWidth")
230
+ form_width.should.be.bounded
231
+ form_width.in_range?(20).should.be.true
232
+ form_width.in_range?(19).should.be.false
233
+
234
+ RemLint::Vocabulary.sysvar("$Ago").bounded?.should.be.false
235
+ end
236
+ end
data/lib/remlint.rb ADDED
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "remlint/version"
4
+ require_relative "remlint/cli"
5
+ require_relative "remlint/config"
6
+ require_relative "remlint/document"
7
+ require_relative "remlint/extractors"
8
+ require_relative "remlint/formatter"
9
+ require_relative "remlint/rules"
10
+ require_relative "remlint/runner"
11
+
12
+ # A style and consistency linter for Remind reminder files.
13
+ #
14
+ # Remind is not Ruby, so none of RuboCop's machinery applies: there is no
15
+ # `parser` AST to walk, and its one escape hatch for non-Ruby input exists to
16
+ # pull Ruby back out of templates. The precedent that does apply is puppet-lint
17
+ # -- a linter written in Ruby for a language that is not Ruby -- including
18
+ # where it draws its line: it checks style and structure, and leaves "is this
19
+ # valid" to the real interpreter.
20
+ #
21
+ # Remind's grammar decides the architecture. The manual describes a file as a
22
+ # list of commands, one per line, with backslash continuation; comments open
23
+ # with `#` or `;`; keywords are case-insensitive and abbreviable; and the `REM`
24
+ # that opens a trigger may be left off entirely. That is line-oriented, not
25
+ # tree-oriented, so there is no grammar here -- there is a pipeline:
26
+ #
27
+ # bytes -> sources -> logical lines -> commands -> tokens -> rules -> offences
28
+ #
29
+ # Each stage is in its own file and each is useful alone. What the vocabulary
30
+ # those stages match against is -- every keyword, its minimum abbreviation, every
31
+ # function's arity, every system variable's range -- is generated from Remind's
32
+ # own dispatch tables by `tasks/generate_tables.rb`, so it is transcription
33
+ # rather than recollection.
34
+ module RemLint
35
+ end