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