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,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rule"
|
|
4
|
+
|
|
5
|
+
module RemLint
|
|
6
|
+
module Rules
|
|
7
|
+
# `%` sequences Remind does not substitute.
|
|
8
|
+
#
|
|
9
|
+
# Narrower than it first looks, and the narrowing is worth stating. The set
|
|
10
|
+
# in `dosubst.c` is closed but *wide*: the switch is on `UPPER(c)`, and
|
|
11
|
+
# every one of the twenty-six letters and all ten digits is a case. So
|
|
12
|
+
# there is no `%z` typo to catch -- `%z` is the seconds-since-epoch
|
|
13
|
+
# sequence. What is left to report is punctuation, and the argument forms
|
|
14
|
+
# that were opened and never closed.
|
|
15
|
+
#
|
|
16
|
+
# It is worth reporting because the failure is invisible. The `default:`
|
|
17
|
+
# branch emits the character *and drops the percent*: `%~` prints `~`, with
|
|
18
|
+
# no error, no warning, and nothing to distinguish it from text somebody
|
|
19
|
+
# meant to type.
|
|
20
|
+
#
|
|
21
|
+
# Four sequences take an argument rather than a single character, and all
|
|
22
|
+
# four are recognised here so their contents are not mistaken for the
|
|
23
|
+
# sequence itself:
|
|
24
|
+
#
|
|
25
|
+
# %<Header> an INFO header (silently empty when absent)
|
|
26
|
+
# %{name} calls subst_name (warns only at warning level 05.00.03)
|
|
27
|
+
# %(text) a translated string
|
|
28
|
+
# %*x the alternate-mode modifier, then a real sequence
|
|
29
|
+
#
|
|
30
|
+
# Leaving one of the first three unterminated is a warning from Remind at
|
|
31
|
+
# run time; reporting it here says the same thing without running the file.
|
|
32
|
+
#
|
|
33
|
+
# A `%` at the very end of a body is *not* an error. It suppresses the
|
|
34
|
+
# newline Remind would otherwise append -- `dosubst("hello")` is six
|
|
35
|
+
# characters and `dosubst("hello%")` is five (remind.1: "if str does not
|
|
36
|
+
# end with %, a newline character will be added"). Remind's own
|
|
37
|
+
# tests/test.rem ends 160 bodies that way.
|
|
38
|
+
#
|
|
39
|
+
# Only reminder bodies are checked. Elsewhere on a line a `%` is an
|
|
40
|
+
# ordinary character -- `BANNER %` is the idiom for no banner at all.
|
|
41
|
+
class UnknownSubstitutionSequence < Rule
|
|
42
|
+
# From the `switch (UPPER(c))` in src/dosubst.c. Every letter and every
|
|
43
|
+
# digit is a case; the punctuation is the part that is actually a set.
|
|
44
|
+
LETTERS = ("A".."Z").to_a.freeze
|
|
45
|
+
DIGITS = ("0".."9").to_a.freeze
|
|
46
|
+
PUNCTUATION = %w[! ? @ # : _ "].freeze
|
|
47
|
+
|
|
48
|
+
# `%%` has no case of its own, but the default branch emits the second
|
|
49
|
+
# `%` -- so it is the working way to write a literal percent, and
|
|
50
|
+
# reporting it would be reporting the fix.
|
|
51
|
+
LITERAL_PERCENT = "%"
|
|
52
|
+
|
|
53
|
+
SIMPLE = (LETTERS + DIGITS + PUNCTUATION + [LITERAL_PERCENT]).freeze
|
|
54
|
+
|
|
55
|
+
# A trailing `%` suppresses the newline; the regex reports it as an empty
|
|
56
|
+
# body, because there is no character after the percent to report.
|
|
57
|
+
TRAILING = ""
|
|
58
|
+
|
|
59
|
+
# An opener with no closer before end of body. Remind warns about each of
|
|
60
|
+
# these at run time ("Unterminated %<...> substitution sequence").
|
|
61
|
+
UNTERMINATED = { "<" => ">", "{" => "}", "(" => ")" }.freeze
|
|
62
|
+
|
|
63
|
+
# The whole sequence, in every form, so the scan advances past an
|
|
64
|
+
# argument rather than stopping inside it.
|
|
65
|
+
SEQUENCE = /%(?<modifier>\*?)(?<body><[^>]*>|\{[^}]*\}|\([^)]*\)|.|\z)/
|
|
66
|
+
|
|
67
|
+
ARGUMENT_FORMS = /\A[<{(]/
|
|
68
|
+
|
|
69
|
+
def self.default_severity
|
|
70
|
+
"warning"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.description
|
|
74
|
+
"A % sequence in a reminder body that Remind does not substitute."
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def check
|
|
78
|
+
document.code_commands.each do |command|
|
|
79
|
+
body = body_of(command)
|
|
80
|
+
|
|
81
|
+
if body
|
|
82
|
+
report(command, body.fetch(:text), body.fetch(:offset))
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def body_of(command)
|
|
90
|
+
trigger = document.trigger_for(command)
|
|
91
|
+
|
|
92
|
+
if trigger.text_body?
|
|
93
|
+
{ text: command.text[trigger.body_offset..].to_s, offset: trigger.body_offset }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def report(command, text, offset)
|
|
98
|
+
text.to_enum(:scan, SEQUENCE).each do
|
|
99
|
+
match = Regexp.last_match
|
|
100
|
+
|
|
101
|
+
unless recognised?(match[:body])
|
|
102
|
+
offend_at(command.logical_line, offset + match.begin(0), message_for(match))
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# A complete argument form, or a single character in the set. An
|
|
108
|
+
# unterminated `%<` arrives here as the one character `<`, which is
|
|
109
|
+
# exactly what distinguishes it from a closed `%<Header>`.
|
|
110
|
+
def recognised?(body)
|
|
111
|
+
body == TRAILING ||
|
|
112
|
+
(body.length > 1 && body.match?(ARGUMENT_FORMS)) ||
|
|
113
|
+
SIMPLE.include?(body.upcase)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def message_for(match)
|
|
117
|
+
body = match[:body]
|
|
118
|
+
closer = UNTERMINATED[body]
|
|
119
|
+
|
|
120
|
+
if closer
|
|
121
|
+
"`%#{body}` is never closed by `#{closer}`"
|
|
122
|
+
else
|
|
123
|
+
"`%#{match[:modifier]}#{body}` is not a substitution sequence; " \
|
|
124
|
+
"Remind drops the `%` and prints `#{body}`"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
__END__
|
|
132
|
+
|
|
133
|
+
require_relative "../document"
|
|
134
|
+
|
|
135
|
+
describe "RemLint::Rules::UnknownSubstitutionSequence" do
|
|
136
|
+
lint = proc do |text|
|
|
137
|
+
source = RemLint::Source.new(path: "t.rem", text: text)
|
|
138
|
+
|
|
139
|
+
RemLint::Rules::UnknownSubstitutionSequence.new.run(RemLint::Document.new(source))
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
messages = proc { |text| lint.(text).map(&:message) }
|
|
143
|
+
|
|
144
|
+
describe "sequences Remind has" do
|
|
145
|
+
it "accepts the letter sequences in either case" do
|
|
146
|
+
lint.("REM 1 Jan MSG Birthday %a and %B and %c\n").should.be.empty
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it "accepts the digit sequences" do
|
|
150
|
+
lint.("REM 1 Jan AT 15:00 MSG Meeting %1 %2 %3 %0\n").should.be.empty
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it "accepts the punctuation sequences" do
|
|
154
|
+
lint.(%(REM 1 Jan MSG a %! b %? c %@ d %# e %: f %_ g\n)).should.be.empty
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
it "accepts the calendar-text delimiter" do
|
|
158
|
+
lint.(%{REM 1 Jan MSG %"Bob's birthday%" is %b\n}).should.be.empty
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it "accepts the alternate-mode modifier" do
|
|
162
|
+
lint.("REM 1 Jan MSG %*l and %*3\n").should.be.empty
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
describe "the argument-taking forms" do
|
|
167
|
+
it "accepts an INFO header reference" do
|
|
168
|
+
lint.("REM 1 Jan INFO \"Location: pub\" MSG Meet at %<Location>\n").should.be.empty
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
it "accepts a substitution-callback call" do
|
|
172
|
+
lint.("REM 1 Jan MSG %{ordinal} of the month\n").should.be.empty
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
it "accepts a translated string" do
|
|
176
|
+
lint.("REM 1 Jan MSG %(Happy birthday)\n").should.be.empty
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
it "does not read the contents of an argument as sequences" do
|
|
180
|
+
# The ~ inside the braces is part of a function name, not a sequence.
|
|
181
|
+
lint.("REM 1 Jan MSG %{a~b}\n").should.be.empty
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
it "reports an unterminated argument form" do
|
|
185
|
+
messages.("REM 1 Jan MSG Meet at %<Location\n").first.should ==
|
|
186
|
+
"`%<` is never closed by `>`"
|
|
187
|
+
messages.("REM 1 Jan MSG %{ordinal\n").first.should == "`%{` is never closed by `}`"
|
|
188
|
+
messages.("REM 1 Jan MSG %(hello\n").first.should == "`%(` is never closed by `)`"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
describe "sequences Remind does not have" do
|
|
193
|
+
it "reports an unknown punctuation mark" do
|
|
194
|
+
messages.("REM 1 Jan MSG See %~ here\n").first.should ==
|
|
195
|
+
"`%~` is not a substitution sequence; Remind drops the `%` and prints `~`"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
it "reports each one on a line" do
|
|
199
|
+
messages.("REM 1 Jan MSG %~ and %$\n").length.should == 2
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
it "points at the percent" do
|
|
203
|
+
text = "REM 1 Jan MSG See %~ here\n"
|
|
204
|
+
|
|
205
|
+
lint.(text).first.column.should == text.index("%~") + 1
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
it "accepts every letter, because every letter is a real sequence" do
|
|
209
|
+
body = ("a".."z").map { |letter| "%#{letter}" }.join(" ")
|
|
210
|
+
|
|
211
|
+
lint.("REM 1 Jan AT 15:00 MSG #{body}\n").should.be.empty
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
it "accepts %% as the literal percent it is" do
|
|
215
|
+
# No case of its own, but the default branch emits the second `%`.
|
|
216
|
+
lint.("REM 1 Jan MSG 50%% off\n").should.be.empty
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
it "accepts a trailing percent, which suppresses the appended newline" do
|
|
220
|
+
# Documented in remind.1, and used 160 times in Remind's own test.rem.
|
|
221
|
+
lint.("REM 1 Jan MSG all done %\n").should.be.empty
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
describe "where it does not look" do
|
|
226
|
+
it "says nothing about a percent in the trigger" do
|
|
227
|
+
lint.("REM 1 Jan MSG hi\n").should.be.empty
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
it "says nothing about BANNER %" do
|
|
231
|
+
# The idiom for no banner at all, and not a reminder body.
|
|
232
|
+
lint.("BANNER %\n").should.be.empty
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
it "says nothing about a percent in a comment" do
|
|
236
|
+
lint.("# 50% done\n").should.be.empty
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
it "says nothing about a command with no body" do
|
|
240
|
+
lint.("SET a 50\n").should.be.empty
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
it "checks a RUN body too" do
|
|
245
|
+
messages.("REM 1 Jan RUN echo %~\n").length.should == 1
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
it "reports at warning severity" do
|
|
249
|
+
lint.("REM 1 Jan MSG %~\n").first.severity.should == "warning"
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
it "reports the physical line inside a continuation" do
|
|
253
|
+
lint.("REM 1 Jan MSG a \\\n %~\n").first.line.should == 2
|
|
254
|
+
end
|
|
255
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rule"
|
|
4
|
+
require_relative "../vocabulary"
|
|
5
|
+
|
|
6
|
+
module RemLint
|
|
7
|
+
module Rules
|
|
8
|
+
# `$Names` Remind does not have.
|
|
9
|
+
#
|
|
10
|
+
# Remind's system-variable namespace is closed: `FindSysVar` binary-searches
|
|
11
|
+
# a fixed table and anything missing is `E_NOSUCH_VAR` (src/var.c). So a
|
|
12
|
+
# typo here is never a variable that happens to be empty -- it is an error,
|
|
13
|
+
# and one that fires only when the line runs, which for a reminder can be
|
|
14
|
+
# months away.
|
|
15
|
+
#
|
|
16
|
+
# The one exception the rule has to know about is `-i` on the command line:
|
|
17
|
+
# `remind -i$Latitude="..."` initialises a variable that is not in the
|
|
18
|
+
# table, and `examples/astro` does exactly that in all four of its heredocs.
|
|
19
|
+
# Those come in as ordinary user variables without the sigil, so they do not
|
|
20
|
+
# reach this rule -- but a file that names its own `$`-prefixed variables
|
|
21
|
+
# can list them under `AllowedNames`.
|
|
22
|
+
class UnknownSystemVariable < Rule
|
|
23
|
+
def self.default_severity
|
|
24
|
+
"error"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.description
|
|
28
|
+
"A $SystemVariable that is not one of Remind's."
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def check
|
|
32
|
+
allowed = option("AllowedNames", []).map(&:downcase)
|
|
33
|
+
|
|
34
|
+
document.logical_lines.each_with_index do |logical_line, index|
|
|
35
|
+
if document.commands[index].code?
|
|
36
|
+
check_line(logical_line, allowed)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def check_line(logical_line, allowed)
|
|
44
|
+
document.tokens_for(logical_line).each do |token|
|
|
45
|
+
if unknown?(token, allowed)
|
|
46
|
+
offend_at(logical_line, token.offset, "`#{token.value}` is not a Remind system variable")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def unknown?(token, allowed)
|
|
52
|
+
token.type == :sysvar &&
|
|
53
|
+
Vocabulary.sysvar(token.value).nil? &&
|
|
54
|
+
!allowed.include?(token.value.delete_prefix("$").downcase)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
__END__
|
|
61
|
+
|
|
62
|
+
require_relative "../document"
|
|
63
|
+
|
|
64
|
+
describe "RemLint::Rules::UnknownSystemVariable" do
|
|
65
|
+
lint = proc do |text, config = {}|
|
|
66
|
+
source = RemLint::Source.new(path: "t.rem", text: text)
|
|
67
|
+
|
|
68
|
+
RemLint::Rules::UnknownSystemVariable.new(config).run(RemLint::Document.new(source))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
messages = proc { |text, config = {}| lint.(text, config).map(&:message) }
|
|
72
|
+
|
|
73
|
+
describe "variables Remind has" do
|
|
74
|
+
it "accepts one in a SET" do
|
|
75
|
+
lint.("SET $AddBlankLines 0\n").should.be.empty
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it "accepts one in a bracketed expression" do
|
|
79
|
+
lint.("INCLUDE [$SysInclude]/ansitext.rem\n").should.be.empty
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it "accepts one whatever its case, as FindSysVar does" do
|
|
83
|
+
lint.("SET $addblanklines 0\n").should.be.empty
|
|
84
|
+
lint.("SET $ADDBLANKLINES 0\n").should.be.empty
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "accepts the trigger variables" do
|
|
88
|
+
lint.("MSG [$T] and [$U]\n").should.be.empty
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
describe "variables Remind does not have" do
|
|
93
|
+
it "reports a typo" do
|
|
94
|
+
messages.("SET $AddBlankLine 0\n").should == ["`$AddBlankLine` is not a Remind system variable"]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it "points at the sigil" do
|
|
98
|
+
lint.("SET $Nope 1\n").first.column.should == 5
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it "reports every one on a line" do
|
|
102
|
+
messages.("MSG [$Nope] and [$AlsoNope]\n").length.should == 2
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it "reports at error severity" do
|
|
106
|
+
lint.("SET $Nope 1\n").first.severity.should == "error"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
describe "what it stays quiet about" do
|
|
111
|
+
it "says nothing about a plain variable with no sigil" do
|
|
112
|
+
lint.("SET Latitude \"45.42\"\n").should.be.empty
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it "says nothing about a name inside a string" do
|
|
116
|
+
lint.(%(MSG "costs $Nope dollars"\n)).should.be.empty
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it "says nothing about comments" do
|
|
120
|
+
lint.("# uses $Nope somewhere\n").should.be.empty
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
it "says nothing about a bare dollar sign" do
|
|
124
|
+
lint.("RUN echo $ done\n").should.be.empty
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
describe "AllowedNames" do
|
|
129
|
+
it "accepts a name the configuration allows" do
|
|
130
|
+
lint.("MSG [$Latitude]\n", "AllowedNames" => ["Latitude"]).should.be.empty
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it "matches an allowed name case-insensitively" do
|
|
134
|
+
lint.("MSG [$latitude]\n", "AllowedNames" => ["Latitude"]).should.be.empty
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it "still reports the names it does not allow" do
|
|
138
|
+
messages.("MSG [$Latitude] [$Nope]\n", "AllowedNames" => ["Latitude"]).length.should == 1
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it "reports the physical line inside a continuation" do
|
|
143
|
+
lint.("SET x 1 + \\\n $Nope\n").first.line.should == 2
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rule"
|
|
4
|
+
|
|
5
|
+
module RemLint
|
|
6
|
+
module Rules
|
|
7
|
+
# Reminder text pasted into a shell command without quotes.
|
|
8
|
+
#
|
|
9
|
+
# `RUN` and `INCLUDECMD` bodies go through Remind's substitution filter and
|
|
10
|
+
# the result is handed to `/bin/sh`. So `%s`, `%<Location>` and `[expr]` are
|
|
11
|
+
# not text in a shell command -- they are *holes* in one, and what lands in
|
|
12
|
+
# them is data: a reminder body, a place name, somebody's surname.
|
|
13
|
+
#
|
|
14
|
+
# REM 1 Jan RUN notify-send [msg] # msg = "Bob's party" -> broken
|
|
15
|
+
# REM 1 Jan RUN notify-send "[msg]" # quoted, and fine
|
|
16
|
+
#
|
|
17
|
+
# An apostrophe is the polite failure. `$(...)`, a backtick or a `;` in the
|
|
18
|
+
# same position is a command running that nobody wrote, from a calendar,
|
|
19
|
+
# under cron, with the user's own privileges. Chapter 17's own INCLUDECMD
|
|
20
|
+
# example is careful to write `L="[lessons]"`; nothing enforces that care.
|
|
21
|
+
#
|
|
22
|
+
# This reports only what it is sure of: a substitution sitting outside any
|
|
23
|
+
# quote. Quoting is tracked by walking the body character by character --
|
|
24
|
+
# single quotes, double quotes, and the backslash escape inside double
|
|
25
|
+
# quotes -- because that is what the shell does with it.
|
|
26
|
+
#
|
|
27
|
+
# A body with no substitution at all is not this rule's business: a fixed
|
|
28
|
+
# `RUN echo hello` has no hole for anything to land in.
|
|
29
|
+
class UnquotedShellSubstitution < Rule
|
|
30
|
+
# Every hole the author did not write the contents of: the `%` sequences
|
|
31
|
+
# in all their forms, and a pasted `[expr]`.
|
|
32
|
+
SUBSTITUTION = /%\*?(?<simple><[^>]*>|\{[^}]*\}|\([^)]*\)|.)|\[[^\]]*\]/
|
|
33
|
+
|
|
34
|
+
# Sequences that expand to something fixed rather than to reminder data,
|
|
35
|
+
# and so are not holes:
|
|
36
|
+
#
|
|
37
|
+
# %" the calendar-text marker, which NORMAL_MODE deletes outright
|
|
38
|
+
# %% a literal percent
|
|
39
|
+
# %_ a newline or a space
|
|
40
|
+
#
|
|
41
|
+
# Remind's own `tests/dedupe.rem` writes `RUN echo %"foo%"`; there is no
|
|
42
|
+
# data in that command for anyone to inject through.
|
|
43
|
+
CONSTANT = %w[" % _].freeze
|
|
44
|
+
|
|
45
|
+
def self.default_severity
|
|
46
|
+
"error"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.description
|
|
50
|
+
"Reminder text pasted into a RUN or INCLUDECMD body outside shell quotes."
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def check
|
|
54
|
+
document.code_commands.each do |command|
|
|
55
|
+
body = shell_body(command)
|
|
56
|
+
|
|
57
|
+
if body
|
|
58
|
+
report_unquoted(command, body.fetch(:text), body.fetch(:offset))
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# The shell text of the command, and where it starts in the logical
|
|
66
|
+
# line, or nil if this command does not run a shell at all.
|
|
67
|
+
def shell_body(command)
|
|
68
|
+
if command.keyword?("INCLUDECMD")
|
|
69
|
+
{ text: command.args, offset: command.args_offset }
|
|
70
|
+
else
|
|
71
|
+
reminder_body(command)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def reminder_body(command)
|
|
76
|
+
trigger = document.trigger_for(command)
|
|
77
|
+
|
|
78
|
+
if trigger.body&.name == "RUN"
|
|
79
|
+
{ text: command.text[trigger.body_offset..].to_s, offset: trigger.body_offset }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def report_unquoted(command, text, offset)
|
|
84
|
+
unquoted_spans(text).each do |span|
|
|
85
|
+
offend_at(
|
|
86
|
+
command.logical_line,
|
|
87
|
+
offset + span.begin(0),
|
|
88
|
+
"`#{span[0]}` is pasted into a shell command unquoted; " \
|
|
89
|
+
"wrap it in double quotes",
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Every substitution in `text` that the shell would see outside quotes.
|
|
95
|
+
def unquoted_spans(text)
|
|
96
|
+
quoted = quote_map(text)
|
|
97
|
+
|
|
98
|
+
text.to_enum(:scan, SUBSTITUTION).filter_map do
|
|
99
|
+
match = Regexp.last_match
|
|
100
|
+
|
|
101
|
+
unless quoted[match.begin(0)] || constant?(match)
|
|
102
|
+
match
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def constant?(match)
|
|
108
|
+
CONSTANT.include?(match[:simple])
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# A flag per character: is the shell inside a quote here? Walked rather
|
|
112
|
+
# than pattern-matched, because whether a quote opens or closes depends
|
|
113
|
+
# on every quote before it.
|
|
114
|
+
def quote_map(text)
|
|
115
|
+
state = nil
|
|
116
|
+
escaped = false
|
|
117
|
+
|
|
118
|
+
text.each_char.map do |character|
|
|
119
|
+
inside = !state.nil?
|
|
120
|
+
state, escaped = step(character, state, escaped)
|
|
121
|
+
inside || !state.nil?
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def step(character, state, escaped)
|
|
126
|
+
if escaped
|
|
127
|
+
[state, false]
|
|
128
|
+
elsif state == '"' && character == "\\"
|
|
129
|
+
[state, true]
|
|
130
|
+
elsif state.nil? && (character == "'" || character == '"')
|
|
131
|
+
[character, false]
|
|
132
|
+
elsif state == character
|
|
133
|
+
[nil, false]
|
|
134
|
+
else
|
|
135
|
+
[state, false]
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
__END__
|
|
143
|
+
|
|
144
|
+
require_relative "../document"
|
|
145
|
+
|
|
146
|
+
describe "RemLint::Rules::UnquotedShellSubstitution" do
|
|
147
|
+
lint = proc do |text|
|
|
148
|
+
source = RemLint::Source.new(path: "t.rem", text: text)
|
|
149
|
+
|
|
150
|
+
RemLint::Rules::UnquotedShellSubstitution.new.run(RemLint::Document.new(source))
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
messages = proc { |text| lint.(text).map(&:message) }
|
|
154
|
+
|
|
155
|
+
describe "RUN bodies" do
|
|
156
|
+
it "reports a bare substitution" do
|
|
157
|
+
messages.("REM 1 Jan RUN notify-send %s\n").length.should == 1
|
|
158
|
+
messages.("REM 1 Jan RUN notify-send %s\n").first.should.match(/`%s` is pasted/)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it "reports a bare pasted expression" do
|
|
162
|
+
messages.("REM 1 Jan RUN notify-send [msg]\n").length.should == 1
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it "accepts a substitution in double quotes" do
|
|
166
|
+
lint.(%(REM 1 Jan RUN notify-send "%s"\n)).should.be.empty
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
it "accepts a substitution in single quotes" do
|
|
170
|
+
lint.("REM 1 Jan RUN notify-send '%s'\n").should.be.empty
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
it "accepts a pasted expression in double quotes" do
|
|
174
|
+
lint.(%(REM 1 Jan RUN notify-send "[msg]"\n)).should.be.empty
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
it "reports the second of two when only the first is quoted" do
|
|
178
|
+
offenses = lint.(%(REM 1 Jan RUN cmd "%s" %t\n))
|
|
179
|
+
|
|
180
|
+
offenses.length.should == 1
|
|
181
|
+
offenses.first.message.should.match(/`%t`/)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
it "is not fooled by a quote that closes before the substitution" do
|
|
185
|
+
lint.(%(REM 1 Jan RUN echo "hello" %s\n)).length.should == 1
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
it "is not fooled by an escaped quote inside a double-quoted string" do
|
|
189
|
+
# The \\" does not close the string, so %s is still inside it.
|
|
190
|
+
lint.(%(REM 1 Jan RUN echo "a \\" %s"\n)).should.be.empty
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
it "treats an apostrophe inside double quotes as text, not a quote" do
|
|
194
|
+
lint.(%(REM 1 Jan RUN echo "Bob's %s"\n)).should.be.empty
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
it "reports the substitution forms that take arguments" do
|
|
198
|
+
messages.("REM 1 Jan RUN cmd %<Location>\n").first.should.match(/%<Location>/)
|
|
199
|
+
messages.("REM 1 Jan RUN cmd %{name}\n").first.should.match(/%\{name\}/)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
it "handles the %* modifier" do
|
|
203
|
+
messages.("REM 1 Jan RUN cmd %*l\n").first.should.match(/`%\*l`/)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
it "says nothing about a RUN body with no substitution at all" do
|
|
207
|
+
lint.("REM 1 Jan RUN /usr/bin/backup --now\n").should.be.empty
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
it "says nothing about the sequences that expand to something fixed" do
|
|
211
|
+
# Straight from tests/dedupe.rem. `%"` is the calendar-text marker and
|
|
212
|
+
# NORMAL_MODE deletes it; there is no data here to inject through.
|
|
213
|
+
lint.(%(REM 8 RUN echo %"foo%"\n)).should.be.empty
|
|
214
|
+
lint.("REM 1 Jan RUN echo 50%% off\n").should.be.empty
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
it "says nothing about a command that only looks like it runs a shell" do
|
|
218
|
+
# `run` here is English, and ERRMSG takes its own argument.
|
|
219
|
+
lint.("ERRMSG Please run [filename()] with the -q option\n").should.be.empty
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
describe "INCLUDECMD" do
|
|
224
|
+
it "reports an unquoted paste" do
|
|
225
|
+
messages.("INCLUDECMD ./lessons [lessons]\n").length.should == 1
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
it "accepts the quoted form the book uses" do
|
|
229
|
+
lint.(%(INCLUDECMD ./gen L="[lessons]"\n)).should.be.empty
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
describe "what it leaves alone" do
|
|
234
|
+
it "says nothing about a MSG body" do
|
|
235
|
+
# A MSG body is printed, not executed.
|
|
236
|
+
lint.("REM 1 Jan MSG Party at [place]\n").should.be.empty
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
it "says nothing about a CAL or SPECIAL body" do
|
|
240
|
+
lint.("REM 1 Jan CAL [place]\n").should.be.empty
|
|
241
|
+
lint.("REM 1 Jan SPECIAL HTML [place]\n").should.be.empty
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
it "says nothing about a substitution in the trigger" do
|
|
245
|
+
lint.("REM [trigger(today())] MSG hi\n").should.be.empty
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
it "says nothing about comments" do
|
|
249
|
+
lint.("# REM 1 Jan RUN cmd %s\n").should.be.empty
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
it "reports at error severity" do
|
|
254
|
+
lint.("REM 1 Jan RUN cmd %s\n").first.severity.should == "error"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
it "points at the substitution" do
|
|
258
|
+
text = "REM 1 Jan RUN cmd %s\n"
|
|
259
|
+
|
|
260
|
+
lint.(text).first.column.should == text.index("%s") + 1
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
it "reports the physical line inside a continuation" do
|
|
264
|
+
lint.("REM 1 Jan RUN cmd \\\n %s\n").first.line.should == 2
|
|
265
|
+
end
|
|
266
|
+
end
|