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,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rule"
|
|
4
|
+
require_relative "../date_literal"
|
|
5
|
+
|
|
6
|
+
module RemLint
|
|
7
|
+
module Rules
|
|
8
|
+
# Repeats that do not repeat what the author meant.
|
|
9
|
+
#
|
|
10
|
+
# A repeat is `*n`: the reminder triggers on its start date and every *n*
|
|
11
|
+
# days after. Two things go wrong with it, and neither is an error.
|
|
12
|
+
#
|
|
13
|
+
# NO START DATE. A repeat counts days from somewhere, and without a fully
|
|
14
|
+
# specified start date there is nothing to count from.
|
|
15
|
+
#
|
|
16
|
+
# A WEEKDAY IN THE TRIGGER. The weekday picks the start date and nothing
|
|
17
|
+
# else -- the recurrence then ignores weekdays entirely. `REM Fri 15 Sep
|
|
18
|
+
# 2025 *10` starts on Friday 19 September and fires every ten days after,
|
|
19
|
+
# on days that are mostly not Fridays. Whatever the `Fri` was for, it was
|
|
20
|
+
# almost certainly not that.
|
|
21
|
+
class RepeatTrigger < Rule
|
|
22
|
+
WEEKDAY_TYPE = "T_WkDay"
|
|
23
|
+
|
|
24
|
+
def self.default_severity
|
|
25
|
+
"warning"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.description
|
|
29
|
+
"A *n repeat with no start date, or with a weekday that does not affect it."
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def check
|
|
33
|
+
document.code_commands.each do |command|
|
|
34
|
+
repeat = repeat_token(command)
|
|
35
|
+
|
|
36
|
+
if repeat
|
|
37
|
+
check_repeat(command, repeat)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# `*10` reaches the lexer as `*` then `10`. Only a command that carries
|
|
45
|
+
# a trigger can carry a repeat -- `SET a 3*14` is multiplication.
|
|
46
|
+
def repeat_token(command)
|
|
47
|
+
trigger = document.trigger_for(command)
|
|
48
|
+
limit = trigger.body_offset || command.text.length
|
|
49
|
+
tokens = document.tokens_for(command.logical_line)
|
|
50
|
+
|
|
51
|
+
if trigger.triggered?
|
|
52
|
+
tokens.each_index.filter_map { |index| star(tokens, index, limit) }.first
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def star(tokens, index, limit)
|
|
57
|
+
token = tokens[index]
|
|
58
|
+
following = tokens[index + 1]
|
|
59
|
+
|
|
60
|
+
if token.type == :other && token.value == "*" &&
|
|
61
|
+
following&.type == :number && token.offset < limit
|
|
62
|
+
token
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def check_repeat(command, repeat)
|
|
67
|
+
tokens = document.tokens_for(command.logical_line)
|
|
68
|
+
|
|
69
|
+
unless start_date(tokens)
|
|
70
|
+
offend_at(command.logical_line, repeat.offset, no_start_message)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
weekday = weekday_clause(command)
|
|
74
|
+
|
|
75
|
+
if weekday
|
|
76
|
+
offend_at(command.logical_line, weekday.offset, weekday_message(weekday))
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# A fully specified date anywhere in the trigger.
|
|
81
|
+
def start_date(tokens)
|
|
82
|
+
tokens.each_index.any? { |index| DateLiteral.at(tokens, index) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def weekday_clause(command)
|
|
86
|
+
document.trigger_for(command).clauses.find do |clause|
|
|
87
|
+
clause.keyword.type == WEEKDAY_TYPE
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def no_start_message
|
|
92
|
+
"a `*n` repeat counts days from a start date, and this trigger has no " \
|
|
93
|
+
"fully specified one to count from"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def weekday_message(clause)
|
|
97
|
+
"`#{clause.name}` only picks the start date; the `*n` repeat that follows " \
|
|
98
|
+
"counts days and ignores weekdays entirely"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
__END__
|
|
105
|
+
|
|
106
|
+
require_relative "../document"
|
|
107
|
+
|
|
108
|
+
describe "RemLint::Rules::RepeatTrigger" do
|
|
109
|
+
lint = proc do |text|
|
|
110
|
+
source = RemLint::Source.new(path: "t.rem", text: text)
|
|
111
|
+
|
|
112
|
+
RemLint::Rules::RepeatTrigger.new.run(RemLint::Document.new(source))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
messages = proc { |text| lint.(text).map(&:message) }
|
|
116
|
+
|
|
117
|
+
describe "repeats that are fine" do
|
|
118
|
+
it "accepts a repeat with an ISO start date" do
|
|
119
|
+
lint.("REM 2012-11-07 *14 MSG Garbage\n").should.be.empty
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it "accepts a repeat with a spelled-out start date" do
|
|
123
|
+
lint.("REM 31 October 2012 *14 MSG Paper recycling\n").should.be.empty
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it "accepts a reminder with no repeat at all" do
|
|
127
|
+
lint.("REM Fri MSG hi\n").should.be.empty
|
|
128
|
+
lint.("REM 1 Jan MSG hi\n").should.be.empty
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
describe "a repeat with no start date" do
|
|
133
|
+
it "is reported" do
|
|
134
|
+
messages.("REM Mon *14 MSG hi\n").first.should.match(/no fully specified one to count from/)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it "is reported for a partial date" do
|
|
138
|
+
messages.("REM 1 Jan *14 MSG hi\n").first.should.match(/no fully specified one/)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it "points at the repeat" do
|
|
142
|
+
text = "REM Mon *14 MSG hi\n"
|
|
143
|
+
|
|
144
|
+
lint.(text).map(&:column).should.include(text.index("*14") + 1)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
describe "a weekday with a repeat" do
|
|
149
|
+
it "is reported" do
|
|
150
|
+
# Straight from chapter 4: starts on 19 Sep, then every ten days.
|
|
151
|
+
messages.("REM Fri 15 Sep 2025 *10 MSG Test\n").first.should ==
|
|
152
|
+
"`FRIDAY` only picks the start date; the `*n` repeat that follows counts days " \
|
|
153
|
+
"and ignores weekdays entirely"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it "points at the weekday" do
|
|
157
|
+
text = "REM Fri 15 Sep 2025 *10 MSG Test\n"
|
|
158
|
+
|
|
159
|
+
lint.(text).first.column.should == text.index("Fri") + 1
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it "is not reported without a repeat" do
|
|
163
|
+
lint.("REM Fri 15 Sep 2025 MSG Test\n").should.be.empty
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
it "reports both faults when a weekday repeat has no start date" do
|
|
167
|
+
lint.("REM Fri *10 MSG Test\n").length.should == 2
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
describe "what it leaves alone" do
|
|
172
|
+
it "does not read a star out of a message body" do
|
|
173
|
+
lint.("REM 2012-11-07 MSG rating *5 stars\n").should.be.empty
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
it "does not read a multiplication as a repeat" do
|
|
177
|
+
lint.("SET a 3*14\n").should.be.empty
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
it "says nothing about comments" do
|
|
181
|
+
lint.("# REM Mon *14 MSG hi\n").should.be.empty
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
it "reports at warning severity" do
|
|
186
|
+
lint.("REM Mon *14 MSG hi\n").first.severity.should == "warning"
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rule"
|
|
4
|
+
|
|
5
|
+
module RemLint
|
|
6
|
+
module Rules
|
|
7
|
+
# `SATISFY` expressions that belong in the trigger.
|
|
8
|
+
#
|
|
9
|
+
# `SATISFY` works by trial: Remind proposes a date, evaluates the
|
|
10
|
+
# expression, and moves on a day if it comes back false. Anything the
|
|
11
|
+
# trigger could have pinned down instead is therefore paid for once per
|
|
12
|
+
# candidate date rather than once.
|
|
13
|
+
#
|
|
14
|
+
# Chapter 7 measures it. Over 100,000 runs the all-SATISFY form evaluates
|
|
15
|
+
# the expression 13,214,377 times and takes 2.30s; moving the day into the
|
|
16
|
+
# trigger drops that to 482,026 evaluations and 0.58s. Same reminder, four
|
|
17
|
+
# times the speed, and the edit is mechanical:
|
|
18
|
+
#
|
|
19
|
+
# REM SATISFY [$Td == 13 && $Tw == 5] -> REM 13 SATISFY [$Tw == 5]
|
|
20
|
+
#
|
|
21
|
+
# Three trigger components can be hoisted, and each is reported only when
|
|
22
|
+
# it is compared with `==` against a literal, which is the form that
|
|
23
|
+
# translates directly into the trigger:
|
|
24
|
+
#
|
|
25
|
+
# $Td the day of the month
|
|
26
|
+
# $Tm the month
|
|
27
|
+
# $Ty the year
|
|
28
|
+
#
|
|
29
|
+
# Two further faults share the same walk. A constraint no date can satisfy
|
|
30
|
+
# -- `$Td == 100` -- costs `$MaxSatIter` iterations and then prints
|
|
31
|
+
# `Can't compute trigger`. And a `$Ty <` term is an `UNTIL` written the
|
|
32
|
+
# expensive way, which the book calls bad practice for the same reason.
|
|
33
|
+
class SatisfyConstraint < Rule
|
|
34
|
+
# The trigger components a trigger can express directly, and the clause
|
|
35
|
+
# each becomes.
|
|
36
|
+
HOISTABLE = {
|
|
37
|
+
"Td" => "a day",
|
|
38
|
+
"Tm" => "a month",
|
|
39
|
+
"Ty" => "a year",
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
RANGES = {
|
|
43
|
+
"Td" => (1..31),
|
|
44
|
+
"Tm" => (1..12),
|
|
45
|
+
"Ty" => (1990..5990),
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
# `$Td == 13` and `$Ty < 2029`, as they arrive from the lexer.
|
|
49
|
+
COMPARISON = /\$(?<name>T[dmy])\s*(?<operator><=|>=|==|!=|<|>)\s*(?<value>\d+)/
|
|
50
|
+
|
|
51
|
+
def self.enabled_by_default?
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.default_severity
|
|
56
|
+
"info"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.description
|
|
60
|
+
"A SATISFY constraint the trigger could express, or that no date satisfies."
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def check
|
|
64
|
+
document.code_commands.each do |command|
|
|
65
|
+
trigger = document.trigger_for(command)
|
|
66
|
+
|
|
67
|
+
if trigger.body&.name == "SATISFY"
|
|
68
|
+
check_satisfy(command, trigger)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def check_satisfy(command, trigger)
|
|
76
|
+
body = command.text[trigger.body_offset..].to_s
|
|
77
|
+
already = pinned(command, trigger)
|
|
78
|
+
|
|
79
|
+
body.to_enum(:scan, COMPARISON).each do
|
|
80
|
+
match = Regexp.last_match
|
|
81
|
+
|
|
82
|
+
report(
|
|
83
|
+
command,
|
|
84
|
+
trigger,
|
|
85
|
+
match,
|
|
86
|
+
already,
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# A component the trigger already names is not one to hoist.
|
|
92
|
+
def pinned(command, trigger)
|
|
93
|
+
tokens = document.tokens_for(command.logical_line)
|
|
94
|
+
limit = trigger.body.offset
|
|
95
|
+
|
|
96
|
+
tokens.select { |token| token.offset < limit }.map(&:type)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def report(command, trigger, match, already)
|
|
100
|
+
name = match[:name]
|
|
101
|
+
value = match[:value].to_i
|
|
102
|
+
offset = trigger.body_offset + match.begin(0)
|
|
103
|
+
|
|
104
|
+
if !RANGES.fetch(name).cover?(value)
|
|
105
|
+
offend_at(command.logical_line, offset, unsatisfiable(name, match))
|
|
106
|
+
elsif match[:operator] == "==" && !already.include?(:number)
|
|
107
|
+
offend_at(command.logical_line, offset, hoistable(name, match))
|
|
108
|
+
elsif name == "Ty" && ["<", "<="].include?(match[:operator])
|
|
109
|
+
offend_at(command.logical_line, offset, bounded(match))
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def unsatisfiable(name, match)
|
|
114
|
+
"`$#{name} #{match[:operator]} #{match[:value]}` can never hold -- " \
|
|
115
|
+
"#{name} runs #{RANGES.fetch(name).first} to #{RANGES.fetch(name).last} -- so " \
|
|
116
|
+
"this costs $MaxSatIter iterations and then prints `Can't compute trigger`"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def hoistable(name, match)
|
|
120
|
+
"`$#{name} == #{match[:value]}` is #{HOISTABLE.fetch(name)} the trigger could " \
|
|
121
|
+
"name directly; hoisting it stops the expression being evaluated once per " \
|
|
122
|
+
"candidate date"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def bounded(match)
|
|
126
|
+
"`$Ty #{match[:operator]} #{match[:value]}` is an `UNTIL` written as a " \
|
|
127
|
+
"constraint; `UNTIL #{match[:value].to_i - 1}-12-31` says the same thing " \
|
|
128
|
+
"without a search"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
__END__
|
|
135
|
+
|
|
136
|
+
require_relative "../document"
|
|
137
|
+
|
|
138
|
+
describe "RemLint::Rules::SatisfyConstraint" do
|
|
139
|
+
lint = proc do |text|
|
|
140
|
+
source = RemLint::Source.new(path: "t.rem", text: text)
|
|
141
|
+
|
|
142
|
+
RemLint::Rules::SatisfyConstraint.new.run(RemLint::Document.new(source))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
messages = proc { |text| lint.(text).map(&:message) }
|
|
146
|
+
|
|
147
|
+
it "is off unless the configuration asks for it" do
|
|
148
|
+
RemLint::Rules::SatisfyConstraint.enabled_by_default?.should.be.false
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
describe "constraints the trigger could express" do
|
|
152
|
+
it "reports a day" do
|
|
153
|
+
messages.("REM SATISFY [$Td == 13]\n").first.should.match(
|
|
154
|
+
/is a day the trigger could name directly/,
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it "reports a month and a year" do
|
|
159
|
+
messages.("REM SATISFY [$Tm == 6]\n").first.should.match(/is a month the trigger could/)
|
|
160
|
+
messages.("REM SATISFY [$Ty == 2027]\n").first.should.match(/is a year the trigger could/)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
it "explains the cost" do
|
|
164
|
+
messages.("REM SATISFY [$Td == 13]\n").first.should.match(
|
|
165
|
+
/once per candidate date/,
|
|
166
|
+
)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
it "accepts a constraint the trigger already pins down" do
|
|
170
|
+
# `REM 13 SATISFY [$Tw == 5]` is the fixed form from chapter 7.
|
|
171
|
+
lint.("REM 13 SATISFY [$Tw == 5]\n").should.be.empty
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
it "says nothing about a weekday, which a trigger cannot pin this way" do
|
|
175
|
+
lint.("REM SATISFY [$Tw == 5]\n").should.be.empty
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
describe "constraints no date satisfies" do
|
|
180
|
+
it "reports a day out of range" do
|
|
181
|
+
messages.("REM SATISFY [$Td == 100]\n").first.should.match(
|
|
182
|
+
/can never hold -- Td runs 1 to 31/,
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
it "names the cost" do
|
|
187
|
+
messages.("REM SATISFY [$Td == 100]\n").first.should.match(
|
|
188
|
+
/costs \$MaxSatIter iterations and then prints `Can't compute trigger`/,
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
it "reports a month out of range" do
|
|
193
|
+
messages.("REM SATISFY [$Tm == 13]\n").first.should.match(/Tm runs 1 to 12/)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
it "reports a year out of range" do
|
|
197
|
+
messages.("REM SATISFY [$Ty == 1970]\n").first.should.match(/Ty runs 1990 to 5990/)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
describe "a year bound written as a constraint" do
|
|
202
|
+
it "is reported as an UNTIL" do
|
|
203
|
+
messages.("REM 13 SATISFY [$Ty < 2029]\n").first.should ==
|
|
204
|
+
"`$Ty < 2029` is an `UNTIL` written as a constraint; `UNTIL 2028-12-31` says " \
|
|
205
|
+
"the same thing without a search"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
it "is reported for <= too" do
|
|
209
|
+
messages.("REM 13 SATISFY [$Ty <= 2029]\n").length.should == 1
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
it "says nothing about a REM with no SATISFY" do
|
|
214
|
+
lint.("REM 13 MSG hi\n").should.be.empty
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
it "says nothing about comments" do
|
|
218
|
+
lint.("# REM SATISFY [$Td == 13]\n").should.be.empty
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
it "points at the constraint" do
|
|
222
|
+
text = "REM SATISFY [$Td == 13]\n"
|
|
223
|
+
|
|
224
|
+
lint.(text).first.column.should == text.index("$Td") + 1
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
it "reports at info severity" do
|
|
228
|
+
lint.("REM SATISFY [$Td == 13]\n").first.severity.should == "info"
|
|
229
|
+
end
|
|
230
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rule"
|
|
4
|
+
|
|
5
|
+
module RemLint
|
|
6
|
+
module Rules
|
|
7
|
+
# `shell()` given a maximum length that returns nothing.
|
|
8
|
+
#
|
|
9
|
+
# `shell(cmd)` reads up to 511 characters of the command's output;
|
|
10
|
+
# `shell(cmd, n)` reads up to *n*. A zero returns the empty string, and a
|
|
11
|
+
# command that produced no output returns the empty string too -- so
|
|
12
|
+
# `shell(cmd, 0)` is indistinguishable from a command that did not work,
|
|
13
|
+
# and debugging starts in the wrong place entirely.
|
|
14
|
+
#
|
|
15
|
+
# A negative *n* is the documented sentinel for "no limit", so only zero is
|
|
16
|
+
# reported.
|
|
17
|
+
class ShellMaxlen < Rule
|
|
18
|
+
SHELL = "shell"
|
|
19
|
+
|
|
20
|
+
def self.default_severity
|
|
21
|
+
"warning"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.description
|
|
25
|
+
"shell() given a maximum length of zero, which always returns nothing."
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def check
|
|
29
|
+
document.logical_lines.each_with_index do |logical_line, index|
|
|
30
|
+
if document.commands[index].code?
|
|
31
|
+
check_line(logical_line)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def check_line(logical_line)
|
|
39
|
+
tokens = document.tokens_for(logical_line)
|
|
40
|
+
|
|
41
|
+
tokens.each_index do |index|
|
|
42
|
+
if call?(tokens, index)
|
|
43
|
+
check_call(logical_line, tokens, index)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def call?(tokens, index)
|
|
49
|
+
tokens[index].type == :function &&
|
|
50
|
+
tokens[index].value.casecmp?(SHELL) &&
|
|
51
|
+
tokens[index + 1]&.type == :lparen
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The second argument, when it is written out as a literal zero. The
|
|
55
|
+
# walk stops at the first top-level comma's operand, so a nested call
|
|
56
|
+
# in the first argument does not confuse it.
|
|
57
|
+
def check_call(logical_line, tokens, index)
|
|
58
|
+
zero = second_argument(tokens, index + 1)
|
|
59
|
+
|
|
60
|
+
if zero
|
|
61
|
+
offend_at(
|
|
62
|
+
logical_line,
|
|
63
|
+
zero.offset,
|
|
64
|
+
"`shell(..., 0)` returns the empty string whatever the command prints, " \
|
|
65
|
+
"which looks exactly like a command that produced nothing",
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def second_argument(tokens, open_index)
|
|
71
|
+
comma = top_level_comma(tokens, open_index)
|
|
72
|
+
argument = comma && tokens[comma + 1]
|
|
73
|
+
|
|
74
|
+
if argument&.type == :number && argument.value.to_i.zero? &&
|
|
75
|
+
tokens[comma + 2]&.type == :rparen
|
|
76
|
+
argument
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def top_level_comma(tokens, open_index)
|
|
81
|
+
depth = 0
|
|
82
|
+
cursor = open_index
|
|
83
|
+
|
|
84
|
+
while cursor < tokens.length
|
|
85
|
+
depth = depth_after(tokens[cursor], depth)
|
|
86
|
+
|
|
87
|
+
if depth.zero?
|
|
88
|
+
break
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
if depth == 1 && tokens[cursor].type == :comma
|
|
92
|
+
break
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
cursor += 1
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
if tokens[cursor]&.type == :comma
|
|
99
|
+
cursor
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def depth_after(token, depth)
|
|
104
|
+
case token.type
|
|
105
|
+
when :lparen, :lbracket then depth + 1
|
|
106
|
+
when :rparen, :rbracket then depth - 1
|
|
107
|
+
else depth
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
__END__
|
|
115
|
+
|
|
116
|
+
require_relative "../document"
|
|
117
|
+
|
|
118
|
+
describe "RemLint::Rules::ShellMaxlen" do
|
|
119
|
+
lint = proc do |text|
|
|
120
|
+
source = RemLint::Source.new(path: "t.rem", text: text)
|
|
121
|
+
|
|
122
|
+
RemLint::Rules::ShellMaxlen.new.run(RemLint::Document.new(source))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it "reports a zero maximum length" do
|
|
126
|
+
lint.(%(SET a shell("ls", 0)\n)).first.message.should.match(/returns the empty string/)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it "accepts the one-argument form" do
|
|
130
|
+
lint.(%(SET a shell("ls")\n)).should.be.empty
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it "accepts a positive maximum length" do
|
|
134
|
+
lint.(%(SET a shell("ls", 1024)\n)).should.be.empty
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it "accepts the negative sentinel" do
|
|
138
|
+
lint.(%(SET a shell("ls", -1)\n)).should.be.empty
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it "is not confused by a comma inside the command" do
|
|
142
|
+
lint.(%(SET a shell("echo a,b")\n)).should.be.empty
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
it "is not confused by a nested call in the first argument" do
|
|
146
|
+
lint.(%(SET a shell(join("ls", "-l"), 1024)\n)).should.be.empty
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it "matches the function name case-insensitively" do
|
|
150
|
+
lint.(%(SET a SHELL("ls", 0)\n)).length.should == 1
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it "points at the zero" do
|
|
154
|
+
text = %(SET a shell("ls", 0)\n)
|
|
155
|
+
|
|
156
|
+
lint.(text).first.column.should == text.rindex("0") + 1
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
it "says nothing about another function with a zero argument" do
|
|
160
|
+
lint.("SET a max(1, 0)\n").should.be.empty
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
it "says nothing about comments" do
|
|
164
|
+
lint.(%(# SET a shell("ls", 0)\n)).should.be.empty
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
it "reports at warning severity" do
|
|
168
|
+
lint.(%(SET a shell("ls", 0)\n)).first.severity.should == "warning"
|
|
169
|
+
end
|
|
170
|
+
end
|