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,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module RemLint
6
+ module Rules
7
+ # Hand-written commands in a file a tool owns.
8
+ #
9
+ # TkRemind appends to its own file; `ics2rem` and `radicale-remind`
10
+ # regenerate theirs from the other side of a sync. A command written into
11
+ # one of those by hand survives until the next write and then does not, and
12
+ # in the meantime the file mixes authored and generated state in something
13
+ # no human is expected to read.
14
+ #
15
+ # Which files a tool owns is not something the linter can work out, so it is
16
+ # configured -- `GeneratedFiles` takes shell globs. With none configured the
17
+ # rule has nothing to say, which is the honest default rather than a guess
18
+ # about anybody's layout.
19
+ #
20
+ # Comments are not reported: a note at the top of a generated file is how
21
+ # people mark it as generated in the first place.
22
+ class GeneratedFileEdited < Rule
23
+ # What the tools write, and so what is *not* a hand edit. TkRemind tags
24
+ # everything it appends.
25
+ GENERATED_TAG = /\bTAG\s+"?TKTAG\d+/i
26
+
27
+ def self.default_severity
28
+ "warning"
29
+ end
30
+
31
+ def self.description
32
+ "Hand-written commands in a file listed under GeneratedFiles."
33
+ end
34
+
35
+ def check
36
+ patterns = option("GeneratedFiles", [])
37
+
38
+ if !patterns.empty? && generated?(patterns)
39
+ report
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def generated?(patterns)
46
+ patterns.any? do |pattern|
47
+ File.fnmatch?(pattern, document.path.to_s, File::FNM_PATHNAME | File::FNM_EXTGLOB)
48
+ end
49
+ end
50
+
51
+ def report
52
+ hand_written.each do |command|
53
+ offend(
54
+ command.line,
55
+ "this file is generated, and a hand-written command in it survives only " \
56
+ "until the tool next writes the file",
57
+ column: command.keyword_column,
58
+ )
59
+ end
60
+ end
61
+
62
+ # Anything the tool did not put there itself.
63
+ def hand_written
64
+ document.code_commands.reject { |command| command.text.match?(GENERATED_TAG) }
65
+ end
66
+ end
67
+ end
68
+ end
69
+
70
+ __END__
71
+
72
+ require_relative "../document"
73
+
74
+ describe "RemLint::Rules::GeneratedFileEdited" do
75
+ lint = proc do |text, path = "calendar.rem", config = {}|
76
+ source = RemLint::Source.new(path: path, text: text)
77
+
78
+ RemLint::Rules::GeneratedFileEdited.new(config).run(RemLint::Document.new(source))
79
+ end
80
+
81
+ owned = { "GeneratedFiles" => ["*.generated.rem", "tkremind/*.rem"] }
82
+
83
+ it "says nothing when no files are declared generated" do
84
+ lint.("REM 1 Jan MSG hi\n").should.be.empty
85
+ end
86
+
87
+ it "says nothing about a file outside the patterns" do
88
+ lint.("REM 1 Jan MSG hi\n", "mine.rem", owned).should.be.empty
89
+ end
90
+
91
+ it "reports a hand-written command in a generated file" do
92
+ offenses = lint.("REM 1 Jan MSG hi\n", "notes.generated.rem", owned)
93
+
94
+ offenses.length.should == 1
95
+ offenses.first.message.should.match(/survives only until the tool next writes the file/)
96
+ end
97
+
98
+ it "matches a pattern with a directory" do
99
+ lint.("REM 1 Jan MSG hi\n", "tkremind/appts.rem", owned).length.should == 1
100
+ end
101
+
102
+ it "does not report what TkRemind wrote itself" do
103
+ text = %(REM 1 Jan TAG "TKTAG1" MSG Appointment\n)
104
+
105
+ lint.(text, "tkremind/appts.rem", owned).should.be.empty
106
+ end
107
+
108
+ it "reports only the hand-written commands among generated ones" do
109
+ text = %(REM 1 Jan TAG "TKTAG1" MSG Generated\nREM 2 Jan MSG By hand\n)
110
+
111
+ lint.(text, "tkremind/appts.rem", owned).map(&:line).should == [2]
112
+ end
113
+
114
+ it "says nothing about comments, which is how files are marked generated" do
115
+ lint.("# Generated by TkRemind -- do not edit\n", "tkremind/a.rem", owned).should.be.empty
116
+ end
117
+
118
+ it "points at the keyword" do
119
+ lint.(" REM 1 Jan MSG hi\n", "a.generated.rem", owned).first.column.should == 4
120
+ end
121
+
122
+ it "reports at warning severity" do
123
+ lint.("REM 1 Jan MSG hi\n", "a.generated.rem", owned).first.severity.should == "warning"
124
+ end
125
+ end
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module RemLint
6
+ module Rules
7
+ # Hebrew dates that cannot exist.
8
+ #
9
+ # `hebdate(day, month, year)` returns `Invalid Hebrew date` for a month
10
+ # Remind does not know and for a day past that month's length. Both are
11
+ # decidable from literals, and both fail on the day the reminder would
12
+ # otherwise have fired.
13
+ #
14
+ # The lengths are `MaxMonLen[]` from src/hbcal.c, and they are *not* the
15
+ # textbook fixed ones: Heshvan and Kislev are recomputed from each year's
16
+ # length -- a Hebrew year runs 353, 354 or 355 days -- so this reports the
17
+ # maximum any year allows. Confirmed against the binary across seven
18
+ # consecutive years.
19
+ #
20
+ # The names are all three of Remind's tables: `HebMonthNames`,
21
+ # `IvritMonthNames` in Hebrew script, and `AltMonthSpellings`, which is why
22
+ # `Tishri`, `Cheshvan`, `Shevat`, `Tammuz`, `Iyyar`, `Adar I` and their
23
+ # Hebrew equivalents are all accepted. Missing that third table reports 31
24
+ # offences against Remind's own tests/test.rem.
25
+ #
26
+ # `hebmon()` returns the canonical transliteration, so a comparison against
27
+ # an accepted-but-non-canonical spelling is never true even where `hebdate`
28
+ # takes it -- which the message says.
29
+ class HebrewDate < Rule
30
+ # `MaxMonLen[]` from src/hbcal.c, in the order its month constants
31
+ # define: Tishrey, Heshvan, Kislev, Tevet, Shvat, Adar A, Adar B,
32
+ # Nisan, Iyar, Sivan, Tamuz, Av, Elul, Adar.
33
+ MAX_LENGTHS = [30, 30, 30, 29, 30, 30, 29, 30, 29, 30, 29, 30, 29, 29].freeze
34
+
35
+ CANONICAL = [
36
+ "Tishrey", "Heshvan", "Kislev", "Tevet", "Shvat", "Adar A", "Adar B",
37
+ "Nisan", "Iyar", "Sivan", "Tamuz", "Av", "Elul", "Adar"
38
+ ].freeze
39
+
40
+ # `IvritMonthNames` -- the same months in Hebrew script.
41
+ IVRIT = [
42
+ "\u05EA\u05E9\u05E8\u05D9", "\u05D7\u05E9\u05D5\u05D5\u05DF",
43
+ "\u05DB\u05E1\u05DC\u05D5", "\u05D8\u05D1\u05EA",
44
+ "\u05E9\u05D1\u05D8", "\u05D0\u05D3\u05E8 \u05D0'",
45
+ "\u05D0\u05D3\u05E8 \u05D1'", "\u05E0\u05D9\u05E1\u05DF",
46
+ "\u05D0\u05D9\u05D9\u05E8", "\u05E1\u05D9\u05D5\u05DF",
47
+ "\u05EA\u05DE\u05D5\u05D6", "\u05D0\u05D1",
48
+ "\u05D0\u05DC\u05D5\u05DC", "\u05D0\u05D3\u05E8"
49
+ ].freeze
50
+
51
+ # `AltMonthSpellings` -- name => the month index it resolves to.
52
+ ALTERNATES = {
53
+ "Tishri" => 0,
54
+ "Tishrei" => 0,
55
+ "Cheshvan" => 1,
56
+ "Kheshvan" => 1,
57
+ "Shevat" => 4,
58
+ "Tammuz" => 10,
59
+ "Adar 1" => 5,
60
+ "Adar I" => 5,
61
+ "\u05D0\u05D3\u05E8 \u05D0" => 5,
62
+ "\u05D0\u05D3\u05E8 1" => 5,
63
+ "\u05D0\u05D3\u05E8 I" => 5,
64
+ "Adar 2" => 6,
65
+ "Adar II" => 6,
66
+ "\u05D0\u05D3\u05E8 \u05D1" => 6,
67
+ "\u05D0\u05D3\u05E8 2" => 6,
68
+ "\u05D0\u05D3\u05E8 II" => 6,
69
+ "Iyyar" => 8,
70
+ }.freeze
71
+
72
+ MONTHS = (
73
+ CANONICAL.each_with_index.to_h { |name, index| [name.downcase, MAX_LENGTHS[index]] }
74
+ .merge(IVRIT.each_with_index.to_h { |name, index| [name, MAX_LENGTHS[index]] })
75
+ .merge(ALTERNATES.transform_keys(&:downcase)
76
+ .transform_values { |index| MAX_LENGTHS[index] })
77
+ ).freeze
78
+
79
+ # `hebdate(30, "Elul", 5790)` and the two-argument form.
80
+ CALL = /\bhebdate\s*\(\s*(?<day>\d+)\s*,\s*"(?<month>[^"]+)"/i
81
+
82
+ def self.default_severity
83
+ "error"
84
+ end
85
+
86
+ def self.description
87
+ "A Hebrew month Remind does not know, or a day past that month's length."
88
+ end
89
+
90
+ def check
91
+ document.logical_lines.each_with_index do |logical_line, index|
92
+ if document.commands[index].code?
93
+ check_line(logical_line)
94
+ end
95
+ end
96
+ end
97
+
98
+ private
99
+
100
+ def check_line(logical_line)
101
+ logical_line.text.to_enum(:scan, CALL).each do
102
+ match = Regexp.last_match
103
+
104
+ report(logical_line, match)
105
+ end
106
+ end
107
+
108
+ def report(logical_line, match)
109
+ month = match[:month].strip
110
+ longest = MONTHS[month.downcase]
111
+
112
+ if longest.nil?
113
+ offend_at(logical_line, match.begin(0), unknown_message(month))
114
+ elsif match[:day].to_i < 1 || match[:day].to_i > longest
115
+ offend_at(logical_line, match.begin(0), length_message(match[:day], month, longest))
116
+ end
117
+ end
118
+
119
+ def unknown_message(month)
120
+ "`#{month}` is not a Hebrew month Remind knows; `hebdate` returns " \
121
+ "`Invalid Hebrew date`, and `hebmon()` returns the canonical spelling, " \
122
+ "so a comparison against this is never true either"
123
+ end
124
+
125
+ def length_message(day, month, longest)
126
+ "`#{month}` never has #{day} days -- #{longest} at most -- so `hebdate` " \
127
+ "returns `Invalid Hebrew date`"
128
+ end
129
+ end
130
+ end
131
+ end
132
+
133
+ __END__
134
+
135
+ require_relative "../document"
136
+
137
+ describe "RemLint::Rules::HebrewDate" do
138
+ lint = proc do |text|
139
+ source = RemLint::Source.new(path: "t.rem", text: text)
140
+
141
+ RemLint::Rules::HebrewDate.new.run(RemLint::Document.new(source))
142
+ end
143
+
144
+ messages = proc { |text| lint.(text).map(&:message) }
145
+
146
+ describe "dates that exist" do
147
+ it "accepts a 30-day month at 30" do
148
+ lint.(%(MSG [hebdate(30, "Nisan", 5790)]\n)).should.be.empty
149
+ lint.(%(MSG [hebdate(30, "Tishrey", 5790)]\n)).should.be.empty
150
+ end
151
+
152
+ it "accepts the months whose length varies by year at 30" do
153
+ # Heshvan and Kislev are 29 or 30 depending on the year's length.
154
+ lint.(%(MSG [hebdate(30, "Heshvan", 5790)]\n)).should.be.empty
155
+ lint.(%(MSG [hebdate(30, "Kislev", 5790)]\n)).should.be.empty
156
+ end
157
+
158
+ it "accepts a 29-day month at 29" do
159
+ lint.(%(MSG [hebdate(29, "Elul", 5790)]\n)).should.be.empty
160
+ end
161
+
162
+ it "accepts the leap-month spellings" do
163
+ lint.(%(MSG [hebdate(29, "Adar A", 5790)]\n)).should.be.empty
164
+ lint.(%(MSG [hebdate(29, "Adar B", 5790)]\n)).should.be.empty
165
+ end
166
+
167
+ it "accepts the alternate spellings Remind lists" do
168
+ # tests/test.rem exercises every one of these.
169
+ %w[Tishri Tishrei Cheshvan Kheshvan Shevat Tammuz Iyyar].each do |name|
170
+ lint.(%(MSG [hebdate(1, "#{name}", 5790)]\n)).should.be.empty
171
+ end
172
+ end
173
+
174
+ it "accepts the Adar variants" do
175
+ ["Adar 1", "Adar I", "Adar 2", "Adar II"].each do |name|
176
+ lint.(%(MSG [hebdate(1, "#{name}", 5790)]\n)).should.be.empty
177
+ end
178
+ end
179
+
180
+ it "accepts the Hebrew-script names" do
181
+ lint.(%(MSG [hebdate(1, "\u05EA\u05E9\u05E8\u05D9", 5790)]\n)).should.be.empty
182
+ lint.(%(MSG [hebdate(1, "\u05D0\u05DC\u05D5\u05DC", 5790)]\n)).should.be.empty
183
+ end
184
+
185
+ it "applies the right length to an alternate spelling" do
186
+ # Cheshvan is Heshvan, which reaches 30; Tammuz is Tamuz, which does not.
187
+ lint.(%(MSG [hebdate(30, "Cheshvan", 5790)]\n)).should.be.empty
188
+ lint.(%(MSG [hebdate(30, "Tammuz", 5790)]\n)).length.should == 1
189
+ end
190
+
191
+ it "matches the month name case-insensitively" do
192
+ lint.(%(MSG [hebdate(29, "elul", 5790)]\n)).should.be.empty
193
+ end
194
+ end
195
+
196
+ describe "dates that cannot exist" do
197
+ it "reports a day past a 29-day month" do
198
+ # The book's example, confirmed against Remind.
199
+ messages.(%(MSG [hebdate(30, "Elul", 5790)]\n)).first.should ==
200
+ "`Elul` never has 30 days -- 29 at most -- so `hebdate` returns `Invalid Hebrew date`"
201
+ end
202
+
203
+ it "reports Tevet at 30" do
204
+ messages.(%(MSG [hebdate(30, "Tevet", 5790)]\n)).length.should == 1
205
+ end
206
+
207
+ it "reports a day past every month" do
208
+ messages.(%(MSG [hebdate(31, "Nisan", 5790)]\n)).length.should == 1
209
+ end
210
+
211
+ it "reports a day of zero" do
212
+ messages.(%(MSG [hebdate(0, "Nisan", 5790)]\n)).length.should == 1
213
+ end
214
+
215
+ it "reports a month Remind does not know" do
216
+ messages.(%(MSG [hebdate(1, "Nosuch", 5790)]\n)).first.should.match(
217
+ /is not a Hebrew month Remind knows/,
218
+ )
219
+ end
220
+
221
+ it "mentions that hebmon returns the canonical spelling" do
222
+ messages.(%(MSG [hebdate(1, "Nosuch", 5790)]\n)).first.should.match(
223
+ /`hebmon\(\)` returns the canonical spelling/,
224
+ )
225
+ end
226
+
227
+ it "points at the call" do
228
+ text = %(MSG [hebdate(30, "Elul", 5790)]\n)
229
+
230
+ lint.(text).first.column.should == text.index("hebdate") + 1
231
+ end
232
+ end
233
+
234
+ it "says nothing about a computed day" do
235
+ lint.(%(MSG [hebdate(d, "Elul", 5790)]\n)).should.be.empty
236
+ end
237
+
238
+ it "says nothing about comments" do
239
+ lint.(%(# MSG [hebdate(30, "Elul", 5790)]\n)).should.be.empty
240
+ end
241
+
242
+ it "reports at error severity" do
243
+ lint.(%(MSG [hebdate(30, "Elul", 5790)]\n)).first.severity.should == "error"
244
+ end
245
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module RemLint
6
+ module Rules
7
+ # `SATISFY` inside an `IFTRIG`.
8
+ #
9
+ # `IFTRIG` accepts any trigger a `REM` would, with exactly one exception:
10
+ # `SATISFY`. The exclusion is trivial to trip over, because the natural way
11
+ # to write an `IFTRIG` is to copy a working `REM` and change the keyword --
12
+ # and every other clause survives that edit.
13
+ class IftrigWithSatisfy < Rule
14
+ def self.default_severity
15
+ "error"
16
+ end
17
+
18
+ def self.description
19
+ "SATISFY inside an IFTRIG, which is the one clause IFTRIG does not take."
20
+ end
21
+
22
+ def check
23
+ document.code_commands.each do |command|
24
+ if command.keyword?("IFTRIG")
25
+ check_iftrig(command)
26
+ end
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def check_iftrig(command)
33
+ satisfy = document.trigger_for(command).body
34
+
35
+ if satisfy&.name == "SATISFY"
36
+ offend_at(
37
+ command.logical_line,
38
+ satisfy.offset,
39
+ "`IFTRIG` takes every clause `REM` does except `SATISFY`",
40
+ )
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ __END__
48
+
49
+ require_relative "../document"
50
+
51
+ describe "RemLint::Rules::IftrigWithSatisfy" do
52
+ lint = proc do |text|
53
+ source = RemLint::Source.new(path: "t.rem", text: text)
54
+
55
+ RemLint::Rules::IftrigWithSatisfy.new.run(RemLint::Document.new(source))
56
+ end
57
+
58
+ it "reports SATISFY in an IFTRIG" do
59
+ offenses = lint.("IFTRIG 13 SATISFY [$Tw == 5]\nMSG Friday the 13th\nENDIF\n")
60
+
61
+ offenses.length.should == 1
62
+ offenses.first.line.should == 1
63
+ offenses.first.message.should.match(/except `SATISFY`/)
64
+ end
65
+
66
+ it "points at the SATISFY" do
67
+ text = "IFTRIG 13 SATISFY [$Tw == 5]\n"
68
+
69
+ lint.(text).first.column.should == text.index("SATISFY") + 1
70
+ end
71
+
72
+ it "accepts an IFTRIG with any other clause" do
73
+ lint.("IFTRIG 1 Jan UNTIL 2027-01-01 AT 15:00\n").should.be.empty
74
+ lint.("IFTRIG Tue SCANFROM -7\n").should.be.empty
75
+ end
76
+
77
+ it "accepts SATISFY on a REM, where it is legal" do
78
+ lint.("REM 13 SATISFY [$Tw == 5] MSG Boo!\n").should.be.empty
79
+ end
80
+
81
+ it "matches an abbreviated IFTRIG" do
82
+ # IFTRIG's minimum abbreviation length is 6, so IFTRIG is the short form.
83
+ lint.("IFTRIG 13 SATISFY [1]\n").length.should == 1
84
+ end
85
+
86
+ it "is case-insensitive" do
87
+ lint.("iftrig 13 satisfy [1]\n").length.should == 1
88
+ end
89
+
90
+ it "says nothing about the word satisfy in a comment" do
91
+ lint.("# IFTRIG and SATISFY do not mix\n").should.be.empty
92
+ end
93
+
94
+ it "reports at error severity" do
95
+ lint.("IFTRIG 13 SATISFY [1]\n").first.severity.should == "error"
96
+ end
97
+
98
+ it "reports the physical line inside a continuation" do
99
+ lint.("IFTRIG 13 \\\n SATISFY [1]\n").first.line.should == 2
100
+ end
101
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module RemLint
6
+ module Rules
7
+ # `INCLUDE` and `SYSINCLUDE` paths that do not mean what they look like.
8
+ #
9
+ # TWO KEYWORDS, TWO BASE DIRECTORIES. `INCLUDE` resolves a relative path
10
+ # against the *working directory*, not against the directory of the file
11
+ # doing the including. The manual calls this a mistake kept for
12
+ # compatibility, and `DO` is the keyword that resolves relative to the
13
+ # file. So `INCLUDE defs.rem` works when you run Remind from the directory
14
+ # the script lives in and fails from anywhere else -- from cron, most
15
+ # memorably.
16
+ #
17
+ # `SYSINCLUDE` with an absolute path is the mirror image: for an absolute
18
+ # path it is simply `INCLUDE`, so the keyword reads as though it pointed
19
+ # into the system include directory when it does not.
20
+ #
21
+ # A path built from an expression -- `[$SysInclude]/ansitext.rem`, which is
22
+ # how `examples/ansitext` does it -- is left alone, because what it
23
+ # resolves to is not decidable here.
24
+ class IncludePath < Rule
25
+ ABSOLUTE = %r{\A/}
26
+
27
+ def self.default_severity
28
+ "warning"
29
+ end
30
+
31
+ def self.description
32
+ "INCLUDE with a relative path, where DO resolves relative to the file, " \
33
+ "or SYSINCLUDE with an absolute one."
34
+ end
35
+
36
+ def check
37
+ document.code_commands.each do |command|
38
+ path = literal_path(command)
39
+
40
+ if path
41
+ report(command, path)
42
+ end
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ # The argument, when it is a plain path rather than something computed.
49
+ def literal_path(command)
50
+ if command.keyword?("INCLUDE", "SYSINCLUDE")
51
+ text = command.args.strip
52
+
53
+ unless text.empty? || text.include?("[") || text.include?("$")
54
+ text
55
+ end
56
+ end
57
+ end
58
+
59
+ def report(command, path)
60
+ if command.keyword?("INCLUDE") && !path.match?(ABSOLUTE)
61
+ offend(command.line, relative_message(path), column: command.keyword_column)
62
+ elsif command.keyword?("SYSINCLUDE") && path.match?(ABSOLUTE)
63
+ offend(command.line, absolute_message(path), column: command.keyword_column)
64
+ end
65
+ end
66
+
67
+ def relative_message(path)
68
+ "`INCLUDE #{path}` resolves against the working directory, not this file's " \
69
+ "directory; `DO #{path}` resolves relative to the file"
70
+ end
71
+
72
+ def absolute_message(path)
73
+ "`SYSINCLUDE #{path}` is just `INCLUDE` for an absolute path, so the keyword " \
74
+ "reads as though it pointed into the system include directory"
75
+ end
76
+ end
77
+ end
78
+ end
79
+
80
+ __END__
81
+
82
+ require_relative "../document"
83
+
84
+ describe "RemLint::Rules::IncludePath" do
85
+ lint = proc do |text|
86
+ source = RemLint::Source.new(path: "t.rem", text: text)
87
+
88
+ RemLint::Rules::IncludePath.new.run(RemLint::Document.new(source))
89
+ end
90
+
91
+ messages = proc { |text| lint.(text).map(&:message) }
92
+
93
+ describe "INCLUDE" do
94
+ it "reports a relative path" do
95
+ messages.("INCLUDE defs.rem\n").first.should ==
96
+ "`INCLUDE defs.rem` resolves against the working directory, not this file's " \
97
+ "directory; `DO defs.rem` resolves relative to the file"
98
+ end
99
+
100
+ it "reports a path with a directory component" do
101
+ messages.("INCLUDE holidays/us.rem\n").length.should == 1
102
+ end
103
+
104
+ it "reports a path that starts with a dot" do
105
+ messages.("INCLUDE ./defs.rem\n").length.should == 1
106
+ end
107
+
108
+ it "accepts an absolute path" do
109
+ lint.("INCLUDE /etc/remind/defs.rem\n").should.be.empty
110
+ end
111
+
112
+ it "accepts DO, which resolves relative to the file" do
113
+ lint.("DO defs.rem\n").should.be.empty
114
+ end
115
+
116
+ it "matches the abbreviated keyword" do
117
+ messages.("INC defs.rem\n").length.should == 1
118
+ end
119
+ end
120
+
121
+ describe "SYSINCLUDE" do
122
+ it "reports an absolute path" do
123
+ messages.("SYSINCLUDE /etc/remind/defs.rem\n").first.should.match(
124
+ /is just `INCLUDE` for an absolute path/,
125
+ )
126
+ end
127
+
128
+ it "accepts a relative path, which is what it is for" do
129
+ lint.("SYSINCLUDE ansitext.rem\n").should.be.empty
130
+ end
131
+ end
132
+
133
+ describe "paths it cannot resolve" do
134
+ it "says nothing about a bracketed expression" do
135
+ # How examples/ansitext writes it.
136
+ lint.("INCLUDE [$SysInclude]/ansitext.rem\n").should.be.empty
137
+ end
138
+
139
+ it "says nothing about a path built from a variable" do
140
+ lint.("INCLUDE $SomeDir/defs.rem\n").should.be.empty
141
+ end
142
+ end
143
+
144
+ it "says nothing about comments" do
145
+ lint.("# INCLUDE defs.rem\n").should.be.empty
146
+ end
147
+
148
+ it "points at the keyword" do
149
+ lint.(" INCLUDE defs.rem\n").first.column.should == 4
150
+ end
151
+
152
+ it "reports at warning severity" do
153
+ lint.("INCLUDE defs.rem\n").first.severity.should == "warning"
154
+ end
155
+ end