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,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module RemLint
6
+ module Rules
7
+ # Callback functions defined with the wrong number of arguments.
8
+ #
9
+ # Remind calls a dozen or so functions by name and by shape. It does not
10
+ # look them up and adapt -- it checks the arity and, if it does not match,
11
+ # walks away:
12
+ #
13
+ # UserFuncExists("msgprefix") == 1 src/dorem.c
14
+ # check_subst_args(func, 3) src/dosubst.c
15
+ #
16
+ # So a `msgprefix(p, q)` is never called. Not called with an error, not
17
+ # called with a warning: the prefix simply does not appear, and the
18
+ # obvious conclusion is that the callback mechanism is broken rather than
19
+ # that the signature is.
20
+ #
21
+ # This is `FunctionArity` turned around. That rule checks calls against the
22
+ # functions they name; this one checks *definitions* against the shape
23
+ # Remind will call them with.
24
+ class CallbackSignature < Rule
25
+ # name => arity Remind insists on.
26
+ #
27
+ # One argument: the prefix and suffix callbacks (given the priority) and
28
+ # the two ordinal helpers. Three: the substitution-filter family, called
29
+ # as `name(altmode, date, time)`.
30
+ ONE_ARGUMENT = %w[msgprefix msgsuffix calprefix calsuffix ordx subst_ampm subst_ordinal].freeze
31
+
32
+ THREE_ARGUMENTS = %w[
33
+ subst_at subst_atx subst_bang subst_bangx subst_colon subst_colonx
34
+ subst_hash subst_hashx subst_question subst_questionx
35
+ ].freeze
36
+
37
+ ARITIES = (
38
+ ONE_ARGUMENT.to_h { |name| [name, 1] }
39
+ .merge(THREE_ARGUMENTS.to_h { |name| [name, 3] })
40
+ ).freeze
41
+
42
+ # `get_function_override` builds `subst_<c>` and `subst_<c>x` for a
43
+ # *single* alphanumeric or underscore character (src/dosubst.c). So
44
+ # `subst_a` and `subst_ax` are callbacks and `subst_a_alt` is not -- it
45
+ # is an ordinary helper, and Remind's own language packs are full of
46
+ # them. Getting this wrong reports 45 offences against `include/lang`.
47
+ OVERRIDE = /\Asubst_[a-z0-9_]x?\z/
48
+
49
+ # A `%{name}` in the file makes `subst_name` a callback, called with the
50
+ # same three arguments.
51
+ NAMED = /%\{(?<name>[^}]*)\}/
52
+
53
+ DEFINITION = /\A(?:-\s*)?(?<name>[A-Za-z_]\w*)\s*\((?<params>[^)]*)\)/
54
+
55
+ def self.default_severity
56
+ "warning"
57
+ end
58
+
59
+ def self.description
60
+ "A Remind callback function defined with the wrong number of arguments."
61
+ end
62
+
63
+ def check
64
+ @named = named_substitutions
65
+
66
+ document.code_commands.each do |command|
67
+ if command.keyword?("FSET")
68
+ check_definition(command)
69
+ end
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def check_definition(command)
76
+ match = command.args.match(DEFINITION)
77
+ expected = match && arity_for(match[:name].downcase)
78
+
79
+ if expected
80
+ compare(command, match, expected)
81
+ end
82
+ end
83
+
84
+ def arity_for(name)
85
+ if ARITIES.key?(name)
86
+ ARITIES.fetch(name)
87
+ elsif name.match?(OVERRIDE) || @named.include?(name)
88
+ 3
89
+ end
90
+ end
91
+
92
+ # `subst_` names this file actually asks Remind to call.
93
+ def named_substitutions
94
+ document.code_commands.flat_map do |command|
95
+ command.text.scan(NAMED).flatten.map { |name| "subst_#{name.downcase}" }
96
+ end
97
+ end
98
+
99
+ def compare(command, match, expected)
100
+ actual = count(match[:params])
101
+
102
+ if actual != expected
103
+ offend(command.line, message(match[:name], expected, actual), column: command.keyword_column)
104
+ end
105
+ end
106
+
107
+ def count(params)
108
+ if params.strip.empty?
109
+ 0
110
+ else
111
+ params.split(",").length
112
+ end
113
+ end
114
+
115
+ def message(name, expected, actual)
116
+ "`#{name}` is a Remind callback and is called with #{expected} " \
117
+ "argument#{expected == 1 ? '' : 's'}, not #{actual}; defined this way it is " \
118
+ "never called at all, and nothing says so"
119
+ end
120
+ end
121
+ end
122
+ end
123
+
124
+ __END__
125
+
126
+ require_relative "../document"
127
+
128
+ describe "RemLint::Rules::CallbackSignature" do
129
+ lint = proc do |text|
130
+ source = RemLint::Source.new(path: "t.rem", text: text)
131
+
132
+ RemLint::Rules::CallbackSignature.new.run(RemLint::Document.new(source))
133
+ end
134
+
135
+ messages = proc { |text| lint.(text).map(&:message) }
136
+
137
+ describe "the one-argument callbacks" do
138
+ it "accepts the right shape" do
139
+ lint.(%(FSET msgprefix(p) "[" + p + "] "\n)).should.be.empty
140
+ lint.(%(FSET msgsuffix(p) char(8)\n)).should.be.empty
141
+ lint.(%(FSET calprefix(p) ""\n)).should.be.empty
142
+ lint.(%(FSET ordx(n) "th"\n)).should.be.empty
143
+ end
144
+
145
+ it "reports two arguments" do
146
+ messages.(%(FSET msgprefix(p, q) ""\n)).first.should ==
147
+ "`msgprefix` is a Remind callback and is called with 1 argument, not 2; " \
148
+ "defined this way it is never called at all, and nothing says so"
149
+ end
150
+
151
+ it "reports none" do
152
+ messages.(%(FSET msgprefix() ""\n)).length.should == 1
153
+ end
154
+
155
+ it "accepts subst_ampm and subst_ordinal, which take one" do
156
+ lint.(%(FSET subst_ampm(x) "am"\n)).should.be.empty
157
+ lint.(%(FSET subst_ordinal(x) "st"\n)).should.be.empty
158
+ end
159
+ end
160
+
161
+ describe "the three-argument substitution family" do
162
+ it "accepts the right shape" do
163
+ lint.(%(FSET subst_at(a, d, t) "at"\n)).should.be.empty
164
+ lint.(%(FSET subst_bangx(a, d, t) "is"\n)).should.be.empty
165
+ end
166
+
167
+ it "reports the wrong shape" do
168
+ messages.(%(FSET subst_at(a, d) "at"\n)).first.should.match(
169
+ /called with 3 arguments, not 2/,
170
+ )
171
+ end
172
+
173
+ it "applies to the single-character overrides Remind builds" do
174
+ messages.(%(FSET subst_a(x) "y"\n)).first.should.match(/called with 3 arguments, not 1/)
175
+ messages.(%(FSET subst_ax(x) "y"\n)).first.should.match(/called with 3 arguments, not 1/)
176
+ lint.(%(FSET subst_a(a, d, t) "y"\n)).should.be.empty
177
+ end
178
+
179
+ it "applies to a subst_ name the file reaches through %{name}" do
180
+ text = %(FSET subst_myown(a) "x"\nREM 1 Jan MSG %{myown}\n)
181
+
182
+ messages.(text).first.should.match(/called with 3 arguments, not 1/)
183
+ end
184
+
185
+ it "leaves an ordinary subst_ helper alone" do
186
+ # include/lang/*.rem define dozens of these, and Remind never calls them
187
+ # -- they are helpers the pack calls itself.
188
+ lint.(%(FSET subst_a_alt(x) "y"\n)).should.be.empty
189
+ lint.(%(FSET subst_hours(x) "y"\n)).should.be.empty
190
+ lint.(%(FSET subst_tdiff(a, b) "y"\n)).should.be.empty
191
+ end
192
+ end
193
+
194
+ describe "what it leaves alone" do
195
+ it "says nothing about an ordinary function" do
196
+ lint.("FSET double(x) x * 2\n").should.be.empty
197
+ lint.("FSET greet(a, b, c, d) a\n").should.be.empty
198
+ end
199
+
200
+ it "matches the callback name case-insensitively" do
201
+ messages.(%(FSET MsgPrefix(p, q) ""\n)).length.should == 1
202
+ end
203
+
204
+ it "accepts the FSET - form and still checks the shape" do
205
+ lint.(%(FSET - msgprefix(p) ""\n)).should.be.empty
206
+ messages.(%(FSET - msgprefix(p, q) ""\n)).length.should == 1
207
+ end
208
+
209
+ it "says nothing about comments" do
210
+ lint.(%(# FSET msgprefix(p, q) ""\n)).should.be.empty
211
+ end
212
+ end
213
+
214
+ it "points at the keyword" do
215
+ lint.(%( FSET msgprefix(p, q) ""\n)).first.column.should == 4
216
+ end
217
+
218
+ it "reports at warning severity" do
219
+ lint.(%(FSET msgprefix(p, q) ""\n)).first.severity.should == "warning"
220
+ end
221
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+ require_relative "../date_literal"
5
+
6
+ module RemLint
7
+ module Rules
8
+ # Clauses given a partial date where a full one is required.
9
+ #
10
+ # `FROM`, `UNTIL`, `THROUGH` and `COMPLETE-THROUGH` all read their argument
11
+ # with `GetFullDate`, which insists on a year, a month and a day. A partial
12
+ # date is `E_PARSE_ERR`.
13
+ #
14
+ # `COMPLETE-THROUGH` is the one that costs most. It is the starting point
15
+ # of a TODO's whole trigger calculation, so a partial date does not merely
16
+ # fail -- when the clause is missing or unusable the algorithm starts at
17
+ # 1990-01-01 and the TODO is overdue by decades on its first run.
18
+ #
19
+ # Only reported when the argument is written out. A computed `[expr]` may
20
+ # yield a perfectly good DATE and is not this rule's business.
21
+ #
22
+ # `OMIT` is excluded entirely. `OMIT ... THROUGH ...` is the omit-range
23
+ # syntax rather than a reminder's expiry, and it takes partial dates on
24
+ # purpose: Remind's own `tests/test.rem` writes `OMIT Jun THROUGH July 15`
25
+ # and `OMIT Apr 2022 through July`.
26
+ class ClauseNeedsFullDate < Rule
27
+ CLAUSES = %w[FROM UNTIL THROUGH COMPLETE-THROUGH].freeze
28
+
29
+ def self.default_severity
30
+ "error"
31
+ end
32
+
33
+ def self.description
34
+ "FROM, UNTIL, THROUGH or COMPLETE-THROUGH given a date that is not fully specified."
35
+ end
36
+
37
+ def check
38
+ document.code_commands.each do |command|
39
+ unless command.keyword?("OMIT")
40
+ check_command(command)
41
+ end
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def check_command(command)
48
+ trigger = document.trigger_for(command)
49
+
50
+ CLAUSES.each do |name|
51
+ check_clause(command, trigger.find(name))
52
+ end
53
+ end
54
+
55
+ def check_clause(command, clause)
56
+ if clause && !computed?(command, clause) && missing(command, clause)
57
+ offend_at(command.logical_line, clause.offset, message(clause, command))
58
+ end
59
+ end
60
+
61
+ # A bracketed expression right after the keyword supplies the date at
62
+ # run time.
63
+ def computed?(command, clause)
64
+ tokens = document.tokens_for(command.logical_line)
65
+
66
+ tokens[clause.argument_index]&.type == :lbracket
67
+ end
68
+
69
+ # The components `GetFullDate` did not get.
70
+ def missing(command, clause)
71
+ tokens = document.tokens_for(command.logical_line)
72
+ date = DateLiteral.at(tokens, clause.argument_index)
73
+
74
+ date.nil?
75
+ end
76
+
77
+ def message(clause, command)
78
+ "`#{clause.name}` needs a full date -- a year, a month and a day -- " \
79
+ "and #{written(command, clause)} is not one"
80
+ end
81
+
82
+ def written(command, clause)
83
+ rest = command.text[clause.end_offset..].to_s
84
+ words = rest.strip.split(/\s+/).first(3).join(" ")
85
+
86
+ "`#{words}`"
87
+ end
88
+ end
89
+ end
90
+ end
91
+
92
+ __END__
93
+
94
+ require_relative "../document"
95
+
96
+ describe "RemLint::Rules::ClauseNeedsFullDate" do
97
+ lint = proc do |text|
98
+ source = RemLint::Source.new(path: "t.rem", text: text)
99
+
100
+ RemLint::Rules::ClauseNeedsFullDate.new.run(RemLint::Document.new(source))
101
+ end
102
+
103
+ messages = proc { |text| lint.(text).map(&:message) }
104
+
105
+ describe "dates that are complete" do
106
+ it "accepts the ISO form" do
107
+ lint.("REM Mon UNTIL 2027-01-01 MSG hi\n").should.be.empty
108
+ end
109
+
110
+ it "accepts the spelled-out form" do
111
+ lint.("REM Mon UNTIL 1 Jan 2027 MSG hi\n").should.be.empty
112
+ lint.("REM Mon FROM Jan 1 2027 MSG hi\n").should.be.empty
113
+ end
114
+
115
+ it "accepts COMPLETE-THROUGH with a full date" do
116
+ lint.("REM Mon COMPLETE-THROUGH 2027-01-01 TODO MSG hi\n").should.be.empty
117
+ end
118
+ end
119
+
120
+ describe "dates that are partial" do
121
+ it "reports a missing year" do
122
+ messages.("REM Mon UNTIL 1 Jan MSG hi\n").first.should ==
123
+ "`UNTIL` needs a full date -- a year, a month and a day -- and `1 Jan MSG` is not one"
124
+ end
125
+
126
+ it "reports a missing day" do
127
+ messages.("REM Mon FROM Jan 2027 MSG hi\n").length.should == 1
128
+ end
129
+
130
+ it "reports a bare year" do
131
+ messages.("REM Mon UNTIL 2027 MSG hi\n").length.should == 1
132
+ end
133
+
134
+ it "reports each clause that is short" do
135
+ messages.("REM Mon FROM 1 Jan UNTIL 2 Feb MSG hi\n").length.should == 2
136
+ end
137
+
138
+ it "reports THROUGH" do
139
+ messages.("REM Mon THROUGH 1 Jan MSG hi\n").first.should.match(/`THROUGH` needs a full date/)
140
+ end
141
+
142
+ it "points at the clause" do
143
+ text = "REM Mon UNTIL 1 Jan MSG hi\n"
144
+
145
+ lint.(text).first.column.should == text.index("UNTIL") + 1
146
+ end
147
+ end
148
+
149
+ describe "arguments it cannot judge" do
150
+ it "says nothing about a computed date" do
151
+ lint.("REM Mon UNTIL [expiry()] MSG hi\n").should.be.empty
152
+ end
153
+ end
154
+
155
+ it "says nothing about a reminder with none of these clauses" do
156
+ lint.("REM Mon MSG hi\n").should.be.empty
157
+ end
158
+
159
+ it "says nothing about SCANFROM, which also takes a delta" do
160
+ lint.("REM Mon SCANFROM -7 MSG hi\n").should.be.empty
161
+ end
162
+
163
+ it "says nothing about THROUGH inside an OMIT, which takes partial dates" do
164
+ # Both forms are in Remind's own tests/test.rem and both work.
165
+ lint.("OMIT Jun THROUGH July 15\n").should.be.empty
166
+ lint.("OMIT Apr 2022 through July\n").should.be.empty
167
+ end
168
+
169
+ it "says nothing about comments" do
170
+ lint.("# REM Mon UNTIL 1 Jan MSG hi\n").should.be.empty
171
+ end
172
+
173
+ it "reports at error severity" do
174
+ lint.("REM Mon UNTIL 1 Jan MSG hi\n").first.severity.should == "error"
175
+ end
176
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module RemLint
6
+ module Rules
7
+ # Clauses that need a time, on a reminder that has none.
8
+ #
9
+ # `AT` is what gives a reminder a time of day. Two other clauses are only
10
+ # meaningful once it does:
11
+ #
12
+ # DURATION a duration with no start time has nothing to be a duration
13
+ # of, and the back-ends have nowhere to place it
14
+ # TZ a bare date cannot be converted between zones -- there is no
15
+ # instant to convert
16
+ #
17
+ # Both fail in the worst way: the reminder parses, runs, and quietly does
18
+ # something other than what the clause says. `TZ` without `AT` is the one
19
+ # that bites months later, when a reminder fires on a day the author never
20
+ # intended.
21
+ #
22
+ # `AT` is not the only way to get a time, though, and Remind's own
23
+ # `include/lunar-eclipses.rem` is 142 lines of the other way:
24
+ #
25
+ # REM NOQUEUE [utctolocal('2097-04-26@10:37')] DURATION 196 MSG ...
26
+ #
27
+ # A pasted expression that evaluates to a DATETIME supplies the date *and*
28
+ # the time, so `DURATION` there is correct. Whether `utctolocal(...)`
29
+ # returns a DATETIME is not decidable without running it -- so a trigger
30
+ # containing any bracketed expression is left alone. Same principle as
31
+ # FunctionArity staying quiet about functions it has not seen defined:
32
+ # where the linter cannot know, it says nothing.
33
+ class ClauseRequiresAt < Rule
34
+ REASONS = {
35
+ "DURATION" => "a duration needs a start time",
36
+ "TZ" => "a bare date has no instant to convert between zones",
37
+ }.freeze
38
+
39
+ def self.default_severity
40
+ "error"
41
+ end
42
+
43
+ def self.description
44
+ "DURATION or TZ on a reminder with no AT clause."
45
+ end
46
+
47
+ def check
48
+ document.code_commands.each do |command|
49
+ check_command(command)
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def check_command(command)
56
+ trigger = document.trigger_for(command)
57
+
58
+ unless trigger.include?("AT") || computed_time?(command, trigger)
59
+ report_orphans(command, trigger)
60
+ end
61
+ end
62
+
63
+ # Any bracketed expression before the body could be the DATETIME that
64
+ # supplies the time.
65
+ def computed_time?(command, trigger)
66
+ limit = trigger.body_offset || command.text.length
67
+
68
+ document.tokens_for(command.logical_line).any? do |token|
69
+ token.type == :lbracket && token.offset < limit
70
+ end
71
+ end
72
+
73
+ def report_orphans(command, trigger)
74
+ REASONS.each do |name, reason|
75
+ clause = trigger.find(name)
76
+
77
+ if clause
78
+ offend_at(
79
+ command.logical_line,
80
+ clause.offset,
81
+ "`#{name}` without an `AT` clause: #{reason}",
82
+ )
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+
90
+ __END__
91
+
92
+ require_relative "../document"
93
+
94
+ describe "RemLint::Rules::ClauseRequiresAt" do
95
+ lint = proc do |text|
96
+ source = RemLint::Source.new(path: "t.rem", text: text)
97
+
98
+ RemLint::Rules::ClauseRequiresAt.new.run(RemLint::Document.new(source))
99
+ end
100
+
101
+ messages = proc { |text| lint.(text).map(&:message) }
102
+
103
+ describe "DURATION" do
104
+ it "is reported without AT" do
105
+ messages.("REM 1 Jan DURATION 1:00 MSG Meeting\n").first.should.match(/`DURATION` without an `AT`/)
106
+ end
107
+
108
+ it "is accepted with AT" do
109
+ lint.("REM 1 Jan AT 15:00 DURATION 1:00 MSG Meeting\n").should.be.empty
110
+ end
111
+
112
+ it "is accepted with AT written after it" do
113
+ lint.("REM 1 Jan DURATION 1:00 AT 15:00 MSG Meeting\n").should.be.empty
114
+ end
115
+ end
116
+
117
+ describe "TZ" do
118
+ it "is reported without AT" do
119
+ messages.("REM 1 Jan TZ America/Toronto MSG hi\n").first.should.match(/`TZ` without an `AT`/)
120
+ end
121
+
122
+ it "is accepted with AT" do
123
+ lint.("REM 1 Jan AT 15:00 TZ America/Toronto MSG hi\n").should.be.empty
124
+ end
125
+ end
126
+
127
+ it "reports both clauses on one reminder" do
128
+ messages.("REM 1 Jan DURATION 1:00 TZ UTC MSG hi\n").length.should == 2
129
+ end
130
+
131
+ it "points at the offending clause" do
132
+ text = "REM 1 Jan DURATION 1:00 MSG Meeting\n"
133
+
134
+ lint.(text).first.column.should == text.index("DURATION") + 1
135
+ end
136
+
137
+ describe "what it leaves alone" do
138
+ it "says nothing about a reminder with neither clause" do
139
+ lint.("REM 1 Jan MSG hi\n").should.be.empty
140
+ end
141
+
142
+ it "says nothing when the time could come from a pasted expression" do
143
+ # Straight out of include/lunar-eclipses.rem: the DATETIME the expression
144
+ # returns carries the time, so DURATION is correct here.
145
+ text = "REM NOQUEUE [utctolocal('2097-04-26@10:37')] DURATION 196 MSG Eclipse\n"
146
+
147
+ lint.(text).should.be.empty
148
+ end
149
+
150
+ it "still reports when the bracket is only in the body" do
151
+ # A body expression cannot supply the trigger's time.
152
+ lint.("REM 1 Jan DURATION 1:00 MSG at [place]\n").length.should == 1
153
+ end
154
+
155
+ it "does not read the words out of a message body" do
156
+ # `duration` and the zone name are English here, not clauses.
157
+ lint.("REM 1 Jan MSG Confirm the duration with the TZ America/Toronto team\n").should.be.empty
158
+ end
159
+
160
+ it "says nothing about comments" do
161
+ lint.("# REM 1 Jan TZ UTC MSG hi\n").should.be.empty
162
+ end
163
+ end
164
+
165
+ it "matches abbreviated clause keywords" do
166
+ # DURATION's minimum length is 8, TZ's is 2, AT's is 2.
167
+ lint.("REM 1 Jan AT 15:00 DURATION 1:00 MSG hi\n").should.be.empty
168
+ messages.("REM 1 Jan TZ UTC MSG hi\n").length.should == 1
169
+ end
170
+
171
+ it "reports at error severity" do
172
+ lint.("REM 1 Jan TZ UTC MSG hi\n").first.severity.should == "error"
173
+ end
174
+
175
+ it "reports the physical line inside a continuation" do
176
+ lint.("REM 1 Jan \\\n TZ UTC MSG hi\n").first.line.should == 2
177
+ end
178
+ end