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,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,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
|