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,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module RemLint
6
+ module Rules
7
+ # `ADDOMIT` with no `SCANFROM`, which adds the wrong year's holiday.
8
+ #
9
+ # `ADDOMIT` puts a movable holiday's computed date into the omit context.
10
+ # But Remind computes a reminder's trigger by scanning forward from today,
11
+ # so the day *after* the holiday the trigger has already moved to next
12
+ # year's occurrence -- and it is next year's date that gets added.
13
+ # Reminders that `SKIP`, `BEFORE` or `AFTER` around the holiday then work
14
+ # perfectly until the day after it, and are quietly wrong from then on.
15
+ #
16
+ # `SCANFROM` fixes it by making the scan start far enough back that the
17
+ # current year's date stays the trigger. Remind warns about a missing one
18
+ # itself, and names the same figure:
19
+ #
20
+ # Warning: Consider using SCANFROM -28 with recurring ADDOMIT
21
+ #
22
+ # This rule says it before the file is ever run, and keeps to Remind's
23
+ # scope: a trigger built from a bracketed expression is opaque to both of
24
+ # us, so neither reports it. `include/holidays/cl.rem` has one
25
+ # (`REM [datepart(soleq(1, $U-28))] ADDOMIT ...`) and Remind is quiet
26
+ # about it, so this is too.
27
+ #
28
+ # The *width* of the window is a judgement rather than a fact, and so is
29
+ # off by default. Chapter 4 works through 28 days, and
30
+ # `include/holidays/us.rem` uses exactly that -- but `examples/defs.rem`
31
+ # uses `SCANFROM -7` twenty-seven times, and that is not a bug: it is a
32
+ # smaller guarantee, adequate for holidays whose dependent reminders are
33
+ # all within a week. Set `MinimumWindow` to 28 to ask for the wider one.
34
+ class AddomitWithoutScanfrom < Rule
35
+ # The figure chapter 4 works through, used only in the message and as
36
+ # the suggestion for a missing SCANFROM.
37
+ RECOMMENDED = 28
38
+
39
+ def self.default_severity
40
+ "warning"
41
+ end
42
+
43
+ def self.description
44
+ "ADDOMIT with no SCANFROM, which adds next year's date the day after the holiday."
45
+ end
46
+
47
+ def check
48
+ document.code_commands.each do |command|
49
+ trigger = document.trigger_for(command)
50
+
51
+ if trigger.include?("ADDOMIT") && !computed?(command, trigger)
52
+ check_scanfrom(command, trigger)
53
+ end
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ # A trigger whose date comes out of an expression. Remind's own
60
+ # warning does not fire on these and neither does this rule.
61
+ def computed?(command, trigger)
62
+ limit = trigger.body_offset || command.text.length
63
+
64
+ document.tokens_for(command.logical_line).any? do |token|
65
+ token.type == :lbracket && token.offset < limit
66
+ end
67
+ end
68
+
69
+ def check_scanfrom(command, trigger)
70
+ scanfrom = trigger.find("SCANFROM")
71
+
72
+ if scanfrom.nil?
73
+ offend_at(command.logical_line, trigger.find("ADDOMIT").offset, missing_message)
74
+ else
75
+ check_window(command, scanfrom)
76
+ end
77
+ end
78
+
79
+ # Zero -- the default -- means "any window will do", which is the
80
+ # honest position: a narrower one is a smaller guarantee, not a fault.
81
+ def check_window(command, scanfrom)
82
+ minimum = option("MinimumWindow", 0)
83
+ days = window(command, scanfrom)
84
+
85
+ if minimum.positive? && days && days < minimum
86
+ offend_at(command.logical_line, scanfrom.offset, narrow_message(days, minimum))
87
+ end
88
+ end
89
+
90
+ # `SCANFROM -28` -- the delta form, which is the one that matters here.
91
+ def window(command, scanfrom)
92
+ text = command.text[scanfrom.end_offset..].to_s.strip[/\A-\d+/]
93
+
94
+ text&.delete("-")&.to_i
95
+ end
96
+
97
+ def missing_message
98
+ "`ADDOMIT` with no `SCANFROM` adds *next* year's date once the day itself has " \
99
+ "passed, so anything skipping around this holiday is wrong from the day after " \
100
+ "it; `SCANFROM -#{RECOMMENDED}` keeps the addition stable"
101
+ end
102
+
103
+ def narrow_message(days, minimum)
104
+ "`SCANFROM -#{days}` is narrower than the #{minimum} days this project asks " \
105
+ "for, so a reminder more than #{days} days from the holiday can still see the " \
106
+ "wrong year's date"
107
+ end
108
+ end
109
+ end
110
+ end
111
+
112
+ __END__
113
+
114
+ require_relative "../document"
115
+
116
+ describe "RemLint::Rules::AddomitWithoutScanfrom" do
117
+ lint = proc do |text, config = {}|
118
+ source = RemLint::Source.new(path: "t.rem", text: text)
119
+
120
+ RemLint::Rules::AddomitWithoutScanfrom.new(config).run(RemLint::Document.new(source))
121
+ end
122
+
123
+ messages = proc { |text, config = {}| lint.(text, config).map(&:message) }
124
+
125
+ wide = { "MinimumWindow" => 28 }
126
+
127
+ it "reports ADDOMIT with no SCANFROM" do
128
+ messages.("REM Mon 1 Apr ADDOMIT MSG Holiday\n").first.should.match(
129
+ /adds \*next\* year's date once the day itself has passed/,
130
+ )
131
+ end
132
+
133
+ it "suggests the window" do
134
+ messages.("REM Mon 1 Apr ADDOMIT MSG Holiday\n").first.should.match(/`SCANFROM -28`/)
135
+ end
136
+
137
+ it "accepts ADDOMIT with a wide enough SCANFROM" do
138
+ lint.("REM Thu Nov 22 SCANFROM -28 ADDOMIT MSG Thanksgiving\n").should.be.empty
139
+ end
140
+
141
+ it "accepts a wider window still" do
142
+ lint.("REM Thu Nov 22 SCANFROM -60 ADDOMIT MSG Thanksgiving\n").should.be.empty
143
+ end
144
+
145
+ it "accepts any window by default" do
146
+ # examples/defs.rem writes SCANFROM -7 twenty-seven times, and that is a
147
+ # smaller guarantee rather than a mistake.
148
+ lint.("REM Thu Nov 22 SCANFROM -7 ADDOMIT MSG Thanksgiving\n").should.be.empty
149
+ end
150
+
151
+ it "reports a narrow window when the project asks for a wider one" do
152
+ messages.("REM Thu Nov 22 SCANFROM -7 ADDOMIT MSG x\n", wide).first.should.match(
153
+ /`SCANFROM -7` is narrower than the 28 days this project asks for/,
154
+ )
155
+ end
156
+
157
+ it "accepts exactly the configured minimum" do
158
+ lint.("REM Thu Nov 22 SCANFROM -28 ADDOMIT MSG x\n", wide).should.be.empty
159
+ end
160
+
161
+ it "says nothing about a reminder with no ADDOMIT" do
162
+ lint.("REM Thu Nov 22 SCANFROM -7 MSG Thanksgiving\n").should.be.empty
163
+ end
164
+
165
+ it "says nothing when SCANFROM takes a date rather than a delta" do
166
+ # A fixed scan date is a different thing and not this rule's to judge.
167
+ lint.("REM Thu Nov 22 SCANFROM 2026-01-01 ADDOMIT MSG x\n", wide).should.be.empty
168
+ end
169
+
170
+ it "points at the ADDOMIT when there is no SCANFROM" do
171
+ text = "REM Mon 1 Apr ADDOMIT MSG Holiday\n"
172
+
173
+ lint.(text).first.column.should == text.index("ADDOMIT") + 1
174
+ end
175
+
176
+ it "says nothing about a trigger built from an expression" do
177
+ # include/holidays/cl.rem has one of these and Remind does not warn about
178
+ # it either -- the trigger is opaque to both of us.
179
+ lint.("REM [datepart(soleq(1, $U-28))] ADDOMIT MSG Solstice\n").should.be.empty
180
+ end
181
+
182
+ it "says nothing about comments" do
183
+ lint.("# REM Mon 1 Apr ADDOMIT MSG Holiday\n").should.be.empty
184
+ end
185
+
186
+ it "reports at warning severity" do
187
+ lint.("REM Mon 1 Apr ADDOMIT MSG x\n").first.severity.should == "warning"
188
+ end
189
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module RemLint
6
+ module Rules
7
+ # Reminders that arrive early without saying so.
8
+ #
9
+ # A delta makes a reminder start appearing before the day it is about, and
10
+ # a time makes it arrive before the hour. Neither changes the body. So
11
+ #
12
+ # REM 16 July ++10 MSG Jane's birthday.
13
+ #
14
+ # prints `Jane's birthday.` on the 8th of July, with nothing to say the
15
+ # birthday is eight days off, and reads exactly like a birthday today.
16
+ # `%b` is the idiomatic fix -- "in 8 days' time" -- and `%2` or `%3` is its
17
+ # equivalent for a time.
18
+ #
19
+ # Off by default, and it will fire on plenty of correct files: an advance
20
+ # warning whose body already reads naturally in both places is fine, and
21
+ # only the author can say. Turning it on is worth it for a file being
22
+ # written rather than one being inherited.
23
+ #
24
+ # The paired rule is `CalendarTextLimited`, which asks you to fence the
25
+ # relative phrase in `%"…%"` so it does not follow the reminder into a
26
+ # calendar box.
27
+ class AdvanceWarningBody < Rule
28
+ # `%a` to `%l` and friends all say something about *when*; these are the
29
+ # ones that say how far away it is.
30
+ RELATIVE = /%\*?[abcefghijkluv]/i
31
+
32
+ TIME_SUBSTITUTION = /%\*?[0-9@#]/
33
+
34
+ DELTA = /(?<!\S)[+-]{1,2}\d+(?!\S)/
35
+
36
+ def self.enabled_by_default?
37
+ false
38
+ end
39
+
40
+ def self.default_severity
41
+ "info"
42
+ end
43
+
44
+ def self.description
45
+ "A reminder with a delta or a time whose body never says when the event is."
46
+ end
47
+
48
+ def check
49
+ document.code_commands.each do |command|
50
+ trigger = document.trigger_for(command)
51
+
52
+ if trigger.text_body?
53
+ check_body(command, trigger)
54
+ end
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def check_body(command, trigger)
61
+ body = command.text[trigger.body_offset..].to_s
62
+
63
+ check_delta(command, trigger, body)
64
+ check_time(command, trigger, body)
65
+ end
66
+
67
+ def check_delta(command, trigger, body)
68
+ if delta?(command, trigger) && !body.match?(RELATIVE)
69
+ offend_at(
70
+ command.logical_line,
71
+ trigger.body.offset,
72
+ "this reminder has a delta, so it appears days before the event, but the " \
73
+ "body never says how far off it is; `%b` reads as \"in 8 days' time\"",
74
+ )
75
+ end
76
+ end
77
+
78
+ def check_time(command, trigger, body)
79
+ if trigger.include?("AT") && !body.match?(TIME_SUBSTITUTION) && !body.match?(RELATIVE)
80
+ offend_at(
81
+ command.logical_line,
82
+ trigger.body.offset,
83
+ "this reminder has an `AT`, so the queued copy arrives before the event, " \
84
+ "but the body never mentions the time; `%2` or `%3` says when",
85
+ )
86
+ end
87
+ end
88
+
89
+ # A delta is written in the trigger, before the body.
90
+ def delta?(command, trigger)
91
+ head = command.text[0...trigger.body.offset].to_s
92
+
93
+ head.match?(DELTA)
94
+ end
95
+ end
96
+ end
97
+ end
98
+
99
+ __END__
100
+
101
+ require_relative "../document"
102
+
103
+ describe "RemLint::Rules::AdvanceWarningBody" do
104
+ lint = proc do |text|
105
+ source = RemLint::Source.new(path: "t.rem", text: text)
106
+
107
+ RemLint::Rules::AdvanceWarningBody.new.run(RemLint::Document.new(source))
108
+ end
109
+
110
+ messages = proc { |text| lint.(text).map(&:message) }
111
+
112
+ it "is off unless the configuration asks for it" do
113
+ RemLint::Rules::AdvanceWarningBody.enabled_by_default?.should.be.false
114
+ end
115
+
116
+ describe "deltas" do
117
+ it "reports a body that never says how far off the event is" do
118
+ messages.("REM 16 July ++10 MSG Jane's birthday.\n").first.should.match(
119
+ /the body never says how far off it is/,
120
+ )
121
+ end
122
+
123
+ it "accepts a body that uses %b" do
124
+ lint.("REM 16 July ++10 MSG Jane's birthday %b.\n").should.be.empty
125
+ end
126
+
127
+ it "accepts a body that uses another relative substitution" do
128
+ lint.("REM 16 July ++10 MSG Jane's birthday %c.\n").should.be.empty
129
+ end
130
+
131
+ it "accepts a single-sign delta with a relative substitution" do
132
+ lint.("REM 16 July +10 MSG Jane's birthday %b.\n").should.be.empty
133
+ end
134
+
135
+ it "says nothing about a reminder with no delta" do
136
+ lint.("REM 16 July MSG Jane's birthday.\n").should.be.empty
137
+ end
138
+
139
+ it "does not read a delta out of the body" do
140
+ lint.("REM 16 July MSG the score was +10 points\n").should.be.empty
141
+ end
142
+ end
143
+
144
+ describe "times" do
145
+ it "reports an AT reminder whose body never mentions the time" do
146
+ messages.("REM Tue AT 15:00 MSG Staff meeting\n").first.should.match(
147
+ /the body never mentions the time/,
148
+ )
149
+ end
150
+
151
+ it "accepts a body that uses %2" do
152
+ lint.("REM Tue AT 15:00 MSG Staff meeting at %2\n").should.be.empty
153
+ end
154
+
155
+ it "accepts a body that uses %3 or %@" do
156
+ lint.("REM Tue AT 15:00 MSG Meeting %3\n").should.be.empty
157
+ lint.("REM Tue AT 15:00 MSG Meeting %@\n").should.be.empty
158
+ end
159
+
160
+ it "says nothing about a reminder with no AT" do
161
+ lint.("REM Tue MSG Staff meeting\n").should.be.empty
162
+ end
163
+ end
164
+
165
+ it "says nothing about a SATISFY body, which is an expression" do
166
+ lint.("REM 16 July ++10 SATISFY [1]\n").should.be.empty
167
+ end
168
+
169
+ it "says nothing about comments" do
170
+ lint.("# REM 16 July ++10 MSG Jane's birthday.\n").should.be.empty
171
+ end
172
+
173
+ it "reports at info severity" do
174
+ lint.("REM 16 July ++10 MSG hi\n").first.severity.should == "info"
175
+ end
176
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module RemLint
6
+ module Rules
7
+ # `BANNER` written more than once, or below the reminders it appears above.
8
+ #
9
+ # `BANNER` sets the line Remind prints before the day's reminders, and
10
+ # there is one of them. A second `BANNER` silently replaces the first, and
11
+ # one written below a `REM` reads as though it applied to the reminders
12
+ # above it when it applies to all of them equally.
13
+ #
14
+ # Neither is an error. The first is a line of configuration that does
15
+ # nothing; the second is a line that does something other than where it
16
+ # sits suggests.
17
+ class BannerPlacement < Rule
18
+ def self.default_severity
19
+ "warning"
20
+ end
21
+
22
+ def self.description
23
+ "A second BANNER, or a BANNER written below the first reminder."
24
+ end
25
+
26
+ def check
27
+ banners = document.code_commands.select { |command| command.keyword?("BANNER") }
28
+
29
+ report_duplicates(banners)
30
+ report_late(banners.first)
31
+ end
32
+
33
+ private
34
+
35
+ def report_duplicates(banners)
36
+ banners.drop(1).each do |banner|
37
+ offend(
38
+ banner.line,
39
+ "a second `BANNER` silently replaces the one on line #{banners.first.line}",
40
+ column: banner.keyword_column,
41
+ )
42
+ end
43
+ end
44
+
45
+ def report_late(banner)
46
+ first = first_reminder
47
+
48
+ if banner && first && banner.line > first.line
49
+ offend(
50
+ banner.line,
51
+ "`BANNER` applies to the whole run, so writing it below the reminder on " \
52
+ "line #{first.line} reads as though it applied only to what follows",
53
+ column: banner.keyword_column,
54
+ )
55
+ end
56
+ end
57
+
58
+ def first_reminder
59
+ document.code_commands.find do |command|
60
+ command.implicit? || command.keyword?("REM")
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ __END__
68
+
69
+ require_relative "../document"
70
+
71
+ describe "RemLint::Rules::BannerPlacement" do
72
+ lint = proc do |text|
73
+ source = RemLint::Source.new(path: "t.rem", text: text)
74
+
75
+ RemLint::Rules::BannerPlacement.new.run(RemLint::Document.new(source))
76
+ end
77
+
78
+ messages = proc { |text| lint.(text).map(&:message) }
79
+
80
+ it "accepts one BANNER above the reminders" do
81
+ lint.("BANNER %\nREM 1 Jan MSG hi\n").should.be.empty
82
+ end
83
+
84
+ it "accepts a file with no BANNER" do
85
+ lint.("REM 1 Jan MSG hi\n").should.be.empty
86
+ end
87
+
88
+ it "reports a second BANNER" do
89
+ messages.("BANNER one\nBANNER two\n").first.should ==
90
+ "a second `BANNER` silently replaces the one on line 1"
91
+ end
92
+
93
+ it "reports each BANNER after the first" do
94
+ lint.("BANNER a\nBANNER b\nBANNER c\n").length.should == 2
95
+ end
96
+
97
+ it "reports a BANNER written below a reminder" do
98
+ messages.("REM 1 Jan MSG hi\nBANNER %\n").first.should.match(
99
+ /reads as though it applied only to what follows/,
100
+ )
101
+ end
102
+
103
+ it "reports a BANNER below an implicit trigger" do
104
+ messages.("1 Jan MSG hi\nBANNER %\n").length.should == 1
105
+ end
106
+
107
+ it "does not count a SET or an INCLUDE as a reminder" do
108
+ lint.("SET a 1\nINCLUDE defs.rem\nBANNER %\n").should.be.empty
109
+ end
110
+
111
+ it "points at the keyword" do
112
+ lint.("BANNER a\n BANNER b\n").first.column.should == 4
113
+ end
114
+
115
+ it "says nothing about the word banner in a body" do
116
+ lint.("REM 1 Jan MSG hang the banner\n").should.be.empty
117
+ end
118
+
119
+ it "says nothing about comments" do
120
+ lint.("# BANNER a\n# BANNER b\n").should.be.empty
121
+ end
122
+
123
+ it "reports at warning severity" do
124
+ lint.("BANNER a\nBANNER b\n").first.severity.should == "warning"
125
+ end
126
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module RemLint
6
+ module Rules
7
+ # A relative phrase that follows the reminder into a calendar box.
8
+ #
9
+ # `%"…%"` marks the part of a body that belongs in a calendar. Outside the
10
+ # marks, text appears in Agenda Mode only. The distinction matters most for
11
+ # exactly the substitutions `AdvanceWarningBody` asks you to add:
12
+ #
13
+ # REM 16 July ++10 MSG %"Jane's birthday%" %b.
14
+ #
15
+ # In Agenda Mode that reads "Jane's birthday in 8 days' time". In a
16
+ # calendar it is printed in the box for 16 July, where "in 8 days' time" is
17
+ # not merely redundant but wrong -- the box already says which day it is.
18
+ #
19
+ # So this is the second half of a pair: one rule asks for `%b`, this one
20
+ # asks you to fence it. Off by default, like its sibling, and for the same
21
+ # reason: a file that is never rendered as a calendar does not need it.
22
+ class CalendarTextLimited < Rule
23
+ RELATIVE = /%\*?[abcefghijkluv]/i
24
+
25
+ MARKER = '%"'
26
+
27
+ def self.enabled_by_default?
28
+ false
29
+ end
30
+
31
+ def self.default_severity
32
+ "info"
33
+ end
34
+
35
+ def self.description
36
+ "A relative-date substitution in a body with no %\" ... %\" calendar limit."
37
+ end
38
+
39
+ def check
40
+ document.code_commands.each do |command|
41
+ trigger = document.trigger_for(command)
42
+
43
+ if trigger.text_body?
44
+ check_body(command, trigger)
45
+ end
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def check_body(command, trigger)
52
+ body = command.text[trigger.body_offset..].to_s
53
+ match = body.match(RELATIVE)
54
+
55
+ if match && !body.include?(MARKER)
56
+ offend_at(
57
+ command.logical_line,
58
+ trigger.body_offset + match.begin(0),
59
+ "`#{match[0]}` says how far off the event is, which is right in Agenda " \
60
+ "Mode and wrong in a calendar box; fence the name in `%\"…%\"` so only " \
61
+ "that reaches the calendar",
62
+ )
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+
69
+ __END__
70
+
71
+ require_relative "../document"
72
+
73
+ describe "RemLint::Rules::CalendarTextLimited" do
74
+ lint = proc do |text|
75
+ source = RemLint::Source.new(path: "t.rem", text: text)
76
+
77
+ RemLint::Rules::CalendarTextLimited.new.run(RemLint::Document.new(source))
78
+ end
79
+
80
+ messages = proc { |text| lint.(text).map(&:message) }
81
+
82
+ it "is off unless the configuration asks for it" do
83
+ RemLint::Rules::CalendarTextLimited.enabled_by_default?.should.be.false
84
+ end
85
+
86
+ it "reports a relative substitution with no calendar limit" do
87
+ messages.("REM 16 July ++10 MSG Jane's birthday %b.\n").first.should.match(
88
+ /wrong in a calendar box/,
89
+ )
90
+ end
91
+
92
+ it "names the substitution it found" do
93
+ messages.("REM 16 July ++10 MSG Jane's birthday %b.\n").first.should.match(/`%b` says/)
94
+ end
95
+
96
+ it "accepts a body that fences the name" do
97
+ lint.(%(REM 16 July ++10 MSG %"Jane's birthday%" %b.\n)).should.be.empty
98
+ end
99
+
100
+ it "accepts a body with no relative substitution at all" do
101
+ lint.("REM 16 July MSG Jane's birthday.\n").should.be.empty
102
+ end
103
+
104
+ it "reports only once per body" do
105
+ lint.("REM 16 July ++10 MSG %b and %c\n").length.should == 1
106
+ end
107
+
108
+ it "points at the substitution" do
109
+ text = "REM 16 July ++10 MSG Jane %b.\n"
110
+
111
+ lint.(text).first.column.should == text.index("%b") + 1
112
+ end
113
+
114
+ it "says nothing about a SATISFY body" do
115
+ lint.("REM 16 July SATISFY [1]\n").should.be.empty
116
+ end
117
+
118
+ it "says nothing about comments" do
119
+ lint.("# REM 16 July ++10 MSG Jane %b.\n").should.be.empty
120
+ end
121
+
122
+ it "reports at info severity" do
123
+ lint.("REM 16 July ++10 MSG Jane %b.\n").first.severity.should == "info"
124
+ end
125
+ end