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,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module RemLint
6
+ module Rules
7
+ # Numeric clause arguments outside the range Remind accepts.
8
+ #
9
+ # Four clauses take a bounded number and all four bounds are in the C:
10
+ #
11
+ # AT hh:mm 0-23 and 0-59
12
+ # DURATION hh:mm minutes 0-59; the hour is unbounded
13
+ # PRIORITY n 0-9999 (`ParsePriority`, src/dorem.c)
14
+ # MAX-OVERDUE n days past due, so a positive count
15
+ #
16
+ # Remind rejects each of them, but only when the line is reached. For a
17
+ # reminder that is the day it triggers, which for an annual one is up to a
18
+ # year after the typo was made.
19
+ #
20
+ # `DURATION` deliberately has no hour ceiling. A duration is a *length*,
21
+ # not a time of day: Remind's own `tests/test3.rem` writes
22
+ # `DURATION 24:45` and `DURATION 48:45` for events running over more than
23
+ # one day, and Remind accepts them. Only the minutes are bounded.
24
+ #
25
+ # `PRIORITY -1` is worth a word: `ParsePriority` starts with `isdigit`, so
26
+ # a minus sign is `Expecting number` rather than an out-of-range value, and
27
+ # the message says so rather than talking about the range.
28
+ class ClauseValueRange < Rule
29
+ MAX_HOUR = 23
30
+ MAX_MINUTE = 59
31
+ MAX_PRIORITY = 9999
32
+
33
+ # Only `AT` is a time of day, so only `AT` has an hour ceiling.
34
+ BOUNDED_HOUR = %w[AT].freeze
35
+
36
+ TIME_CLAUSES = %w[AT DURATION].freeze
37
+
38
+ # `15:00`, `15.00` -- Remind takes either separator.
39
+ TIME = /\A(?<hour>\d{1,2})[:.](?<minute>\d{1,2})\z/i
40
+
41
+ AM_PM = /\A(?<hour>\d{1,2})([:.](?<minute>\d{1,2}))?\s*(?<half>am|pm)\z/i
42
+
43
+ def self.default_severity
44
+ "error"
45
+ end
46
+
47
+ def self.description
48
+ "An AT, DURATION, PRIORITY or MAX-OVERDUE value outside its range."
49
+ end
50
+
51
+ def check
52
+ document.code_commands.each do |command|
53
+ trigger = document.trigger_for(command)
54
+
55
+ TIME_CLAUSES.each { |name| check_time(command, trigger.find(name)) }
56
+ check_priority(command, trigger.find("PRIORITY"))
57
+ check_max_overdue(command, trigger.find("MAX-OVERDUE"))
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ # The literal word after a clause keyword, or nil when the argument is
64
+ # computed and so not this rule's to judge.
65
+ def argument(command, clause)
66
+ if clause
67
+ rest = command.text[clause.end_offset..].to_s
68
+
69
+ rest.strip[/\A\S+/]
70
+ end
71
+ end
72
+
73
+ def check_time(command, clause)
74
+ text = argument(command, clause)
75
+ match = text && (text.match(TIME) || text.match(AM_PM))
76
+
77
+ if match
78
+ report_time(command, clause, match)
79
+ end
80
+ end
81
+
82
+ def report_time(command, clause, match)
83
+ hour = match[:hour].to_i
84
+ minute = match[:minute].to_i
85
+ if am_pm?(match)
86
+ limit = 12
87
+ else
88
+ limit = MAX_HOUR
89
+ end
90
+
91
+ if BOUNDED_HOUR.include?(clause.name) && hour > limit
92
+ offend_at(command.logical_line, clause.offset, hour_message(clause, hour, limit))
93
+ elsif minute > MAX_MINUTE
94
+ offend_at(command.logical_line, clause.offset, minute_message(clause, minute))
95
+ end
96
+ end
97
+
98
+ def am_pm?(match)
99
+ match.names.include?("half") && !match[:half].nil?
100
+ end
101
+
102
+ def hour_message(clause, hour, limit)
103
+ "`#{clause.name} #{hour}:...` has an hour of #{hour}; Remind accepts 0 to #{limit}"
104
+ end
105
+
106
+ def minute_message(clause, minute)
107
+ "`#{clause.name}` has a minute of #{minute}; Remind accepts 0 to #{MAX_MINUTE}"
108
+ end
109
+
110
+ def check_priority(command, clause)
111
+ text = argument(command, clause)
112
+
113
+ if text&.match?(/\A-\d+\z/)
114
+ offend_at(
115
+ command.logical_line,
116
+ clause.offset,
117
+ "`PRIORITY #{text}` is rejected as `Expecting number`; " \
118
+ "ParsePriority reads digits only, so a minus never reaches the range check",
119
+ )
120
+ elsif text&.match?(/\A\d+\z/) && text.to_i > MAX_PRIORITY
121
+ offend_at(
122
+ command.logical_line,
123
+ clause.offset,
124
+ "`PRIORITY #{text}` is outside 0 to #{MAX_PRIORITY}",
125
+ )
126
+ end
127
+ end
128
+
129
+ def check_max_overdue(command, clause)
130
+ text = argument(command, clause)
131
+
132
+ if text&.match?(/\A-?\d+\z/) && text.to_i < 1
133
+ offend_at(
134
+ command.logical_line,
135
+ clause.offset,
136
+ "`MAX-OVERDUE #{text}` counts days past the due date, so it has to be " \
137
+ "positive; #{text} switches the nagging off rather than configuring it",
138
+ )
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
144
+
145
+ __END__
146
+
147
+ require_relative "../document"
148
+
149
+ describe "RemLint::Rules::ClauseValueRange" do
150
+ lint = proc do |text|
151
+ source = RemLint::Source.new(path: "t.rem", text: text)
152
+
153
+ RemLint::Rules::ClauseValueRange.new.run(RemLint::Document.new(source))
154
+ end
155
+
156
+ messages = proc { |text| lint.(text).map(&:message) }
157
+
158
+ describe "AT times" do
159
+ it "accepts a valid time" do
160
+ lint.("REM Tue AT 15:00 MSG hi\n").should.be.empty
161
+ lint.("REM Tue AT 0:00 MSG hi\n").should.be.empty
162
+ lint.("REM Tue AT 23:59 MSG hi\n").should.be.empty
163
+ end
164
+
165
+ it "accepts the dot separator" do
166
+ lint.("REM Tue AT 15.00 MSG hi\n").should.be.empty
167
+ end
168
+
169
+ it "reports an hour past 23" do
170
+ messages.("REM Tue AT 25:00 MSG hi\n").first.should.match(/an hour of 25; Remind accepts 0 to 23/)
171
+ end
172
+
173
+ it "reports a minute past 59" do
174
+ messages.("REM Tue AT 9:70 MSG hi\n").first.should.match(/a minute of 70/)
175
+ end
176
+
177
+ it "accepts an am/pm time" do
178
+ lint.("REM Tue AT 3:00PM MSG hi\n").should.be.empty
179
+ lint.("REM Tue AT 11:59am MSG hi\n").should.be.empty
180
+ end
181
+
182
+ it "reports an am/pm hour past 12" do
183
+ messages.("REM Tue AT 13:00PM MSG hi\n").first.should.match(/Remind accepts 0 to 12/)
184
+ end
185
+ end
186
+
187
+ describe "DURATION" do
188
+ it "accepts a valid duration" do
189
+ lint.("REM Tue AT 15:00 DURATION 1:30 MSG hi\n").should.be.empty
190
+ end
191
+
192
+ it "reports a minute past 59" do
193
+ messages.("REM Tue AT 15:00 DURATION 1:75 MSG hi\n").first.should.match(/`DURATION`/)
194
+ end
195
+
196
+ it "accepts an hour past 23, because a duration is a length" do
197
+ # tests/test3.rem writes DURATION 24:45 and DURATION 48:45; Remind
198
+ # accepts both -- the event simply runs over more than one day.
199
+ lint.("REM Tue AT 11:00 DURATION 24:45 MSG hi\n").should.be.empty
200
+ lint.("REM Tue AT 11:00 DURATION 48:45 MSG hi\n").should.be.empty
201
+ end
202
+ end
203
+
204
+ describe "PRIORITY" do
205
+ it "accepts the range" do
206
+ lint.("REM Tue PRIORITY 0 MSG hi\n").should.be.empty
207
+ lint.("REM Tue PRIORITY 9999 MSG hi\n").should.be.empty
208
+ end
209
+
210
+ it "reports a value past 9999" do
211
+ messages.("REM Tue PRIORITY 10000 MSG hi\n").first.should ==
212
+ "`PRIORITY 10000` is outside 0 to 9999"
213
+ end
214
+
215
+ it "explains that a negative is a parse error, not a range one" do
216
+ messages.("REM Tue PRIORITY -1 MSG hi\n").first.should.match(
217
+ /rejected as `Expecting number`/,
218
+ )
219
+ end
220
+ end
221
+
222
+ describe "MAX-OVERDUE" do
223
+ it "accepts a positive count" do
224
+ lint.("REM Tue MAX-OVERDUE 5 MSG hi\n").should.be.empty
225
+ end
226
+
227
+ it "reports zero" do
228
+ messages.("REM Tue MAX-OVERDUE 0 MSG hi\n").first.should.match(/has to be positive/)
229
+ end
230
+
231
+ it "reports a negative" do
232
+ messages.("REM Tue MAX-OVERDUE -3 MSG hi\n").length.should == 1
233
+ end
234
+ end
235
+
236
+ describe "arguments it cannot judge" do
237
+ it "says nothing about a computed time" do
238
+ lint.("REM Tue AT [sunset()] MSG hi\n").should.be.empty
239
+ end
240
+
241
+ it "says nothing about a computed priority" do
242
+ lint.("REM Tue PRIORITY [p] MSG hi\n").should.be.empty
243
+ end
244
+ end
245
+
246
+ it "does not read a time out of a message body" do
247
+ lint.("REM Tue MSG Meeting at 25:00 in the old money\n").should.be.empty
248
+ end
249
+
250
+ it "says nothing about comments" do
251
+ lint.("# REM Tue AT 25:00 MSG hi\n").should.be.empty
252
+ end
253
+
254
+ it "reports at error severity" do
255
+ lint.("REM Tue AT 25:00 MSG hi\n").first.severity.should == "error"
256
+ end
257
+ end
@@ -0,0 +1,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module RemLint
6
+ module Rules
7
+ # Colour components outside 0-255.
8
+ #
9
+ # Remind rejects them -- `if (r > 255 || g > 255 || b > 255) return E_2HIGH`
10
+ # in src/funcs.c for `ansicolor`, and the same bound in src/var.c for
11
+ # `$DefaultColor` -- but only when the line runs, and `rem2ps` silently
12
+ # clamps instead (src/rem2ps.c). A component typed as `2555` therefore
13
+ # renders as something plausible in one output mode and fails in another,
14
+ # which is the worst way for a mistake to behave.
15
+ #
16
+ # Three places carry components, and all three are hand-typed integers in
17
+ # practice, which is exactly when a linter can help:
18
+ #
19
+ # SPECIAL COLOR r g b <text> examples/astro has sixteen of these
20
+ # SPECIAL SHADE r g b tests/shade.rem has one per weekday
21
+ # [ansicolor(r, g, b)] examples/ansitext, examples/alignment
22
+ #
23
+ # `ansicolor` also takes a one-argument form (`ansicolor("")`, the reset)
24
+ # and an optional fourth background flag; only the three components are
25
+ # range-checked, and only when they are literal integers.
26
+ class ColorComponentRange < Rule
27
+ MINIMUM = 0
28
+ MAXIMUM = 255
29
+
30
+ # `COLOR`, and the spelling Remind also accepts.
31
+ SPECIAL_KINDS = %w[COLOR COLOUR SHADE].freeze
32
+
33
+ # `SPECIAL COLOR 255 128 0 rest...` -- three integers after the keyword.
34
+ SPECIAL = /\A(?<kind>[A-Za-z]+)\s+(?<r>-?\d+)\s+(?<g>-?\d+)\s+(?<b>-?\d+)\b/
35
+
36
+ CHANNELS = %w[red green blue].freeze
37
+
38
+ def self.default_severity
39
+ "error"
40
+ end
41
+
42
+ def self.description
43
+ "Colour components outside the 0-255 Remind accepts."
44
+ end
45
+
46
+ def check
47
+ document.code_commands.each do |command|
48
+ check_special(command)
49
+ check_ansicolor(command)
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def check_special(command)
56
+ match = special_components(command)
57
+
58
+ if match
59
+ report_components(command, match[:kind].upcase, [match[:r], match[:g], match[:b]])
60
+ end
61
+ end
62
+
63
+ # The components sit after `SPECIAL`, which may itself be preceded by a
64
+ # whole trigger (`REM [moondatetime(0)] +60 SPECIAL COLOR ...`), so the
65
+ # keyword is found in the text rather than assumed to open the line.
66
+ def special_components(command)
67
+ rest = command.text[/\bSPECIAL\s+(.*)\z/mi, 1]
68
+ match = rest&.match(SPECIAL)
69
+
70
+ if match && SPECIAL_KINDS.include?(match[:kind].upcase)
71
+ match
72
+ end
73
+ end
74
+
75
+ def check_ansicolor(command)
76
+ logical_line = command.logical_line
77
+ tokens = document.tokens_for(logical_line)
78
+
79
+ tokens.each_with_index do |token, index|
80
+ if token.type == :function && token.value.casecmp?("ansicolor")
81
+ report_components(command, "ansicolor", literal_arguments(tokens, index))
82
+ end
83
+ end
84
+ end
85
+
86
+ # The first three arguments, and only when each is an integer literal.
87
+ # `ansicolor("")` and `ansicolor(r, g, b)` with computed components
88
+ # both yield nothing to check.
89
+ #
90
+ # The arguments are split on top-level commas rather than picked out by
91
+ # position, because the lexer emits a minus sign as its own token:
92
+ # `ansicolor(-1, 0, 0)` reads as `-`, `1`, `,`, ... and taking the
93
+ # numbers alone would see a perfectly in-range 1.
94
+ def literal_arguments(tokens, index)
95
+ split_arguments(tokens, index + 1).first(3).filter_map { |group| integer(group) }
96
+ end
97
+
98
+ def split_arguments(tokens, open_index)
99
+ groups = [[]]
100
+ depth = 0
101
+ cursor = open_index
102
+
103
+ if tokens[cursor]&.type != :lparen
104
+ groups = []
105
+ else
106
+ depth, cursor = 1, cursor + 1
107
+
108
+ while cursor < tokens.length && depth.positive?
109
+ depth = collect(tokens[cursor], groups, depth)
110
+ cursor += 1
111
+ end
112
+ end
113
+
114
+ groups
115
+ end
116
+
117
+ # Returns the depth after this token, having filed the token under the
118
+ # argument it belongs to. The call's own closing paren is the one thing
119
+ # not filed anywhere -- it ends the walk instead.
120
+ def collect(token, groups, depth)
121
+ case token.type
122
+ when :lparen, :lbracket then descend(groups, token, depth)
123
+ when :rparen, :rbracket then ascend(groups, token, depth)
124
+ when :comma then comma(groups, token, depth)
125
+ else
126
+ groups.last << token
127
+ depth
128
+ end
129
+ end
130
+
131
+ def descend(groups, token, depth)
132
+ groups.last << token
133
+
134
+ depth + 1
135
+ end
136
+
137
+ def ascend(groups, token, depth)
138
+ if depth > 1
139
+ groups.last << token
140
+ end
141
+
142
+ depth - 1
143
+ end
144
+
145
+ def comma(groups, token, depth)
146
+ if depth == 1
147
+ groups << []
148
+ else
149
+ groups.last << token
150
+ end
151
+
152
+ depth
153
+ end
154
+
155
+ # A group is a literal only if it is a bare number, optionally signed.
156
+ def integer(group)
157
+ text = group.map(&:value).join
158
+
159
+ if text.match?(/\A[+-]?\d+\z/)
160
+ text
161
+ end
162
+ end
163
+
164
+ def report_components(command, kind, components)
165
+ components.each_with_index do |component, index|
166
+ value = Integer(component, exception: false)
167
+
168
+ if !value.nil? && !(MINIMUM..MAXIMUM).cover?(value)
169
+ offend(
170
+ command.line,
171
+ "#{kind} #{CHANNELS.fetch(index)} component #{value} is outside " \
172
+ "#{MINIMUM}-#{MAXIMUM}",
173
+ column: command.keyword_column,
174
+ )
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
181
+
182
+ __END__
183
+
184
+ require_relative "../document"
185
+
186
+ describe "RemLint::Rules::ColorComponentRange" do
187
+ lint = proc do |text|
188
+ source = RemLint::Source.new(path: "t.rem", text: text)
189
+
190
+ RemLint::Rules::ColorComponentRange.new.run(RemLint::Document.new(source))
191
+ end
192
+
193
+ messages = proc { |text| lint.(text).map(&:message) }
194
+
195
+ describe "SPECIAL COLOR" do
196
+ it "accepts components in range" do
197
+ lint.("REM Mon SPECIAL COLOR 255 128 0 Sunset\n").should.be.empty
198
+ end
199
+
200
+ it "accepts the boundary values" do
201
+ lint.("SPECIAL COLOR 0 0 0 x\nSPECIAL COLOR 255 255 255 y\n").should.be.empty
202
+ end
203
+
204
+ it "reports a component above 255" do
205
+ messages.("SPECIAL COLOR 2555 128 0 x\n").should ==
206
+ ["COLOR red component 2555 is outside 0-255"]
207
+ end
208
+
209
+ it "reports a negative component" do
210
+ messages.("SPECIAL COLOR 255 -1 0 x\n").should ==
211
+ ["COLOR green component -1 is outside 0-255"]
212
+ end
213
+
214
+ it "names the blue channel" do
215
+ messages.("SPECIAL COLOR 0 0 999 x\n").should ==
216
+ ["COLOR blue component 999 is outside 0-255"]
217
+ end
218
+
219
+ it "reports every bad component on the line" do
220
+ messages.("SPECIAL COLOR 300 400 500 x\n").length.should == 3
221
+ end
222
+
223
+ it "accepts the COLOUR spelling" do
224
+ messages.("SPECIAL COLOUR 300 0 0 x\n").should ==
225
+ ["COLOUR red component 300 is outside 0-255"]
226
+ end
227
+
228
+ it "finds the components after a full trigger, as astro writes them" do
229
+ messages.("REM [moondatetime(0)] +60 SPECIAL COLOR 300 0 0 New moon\n").length.should == 1
230
+ end
231
+ end
232
+
233
+ describe "SPECIAL SHADE" do
234
+ it "accepts components in range" do
235
+ lint.("REM Mon SPECIAL SHADE 255 255 204\n").should.be.empty
236
+ end
237
+
238
+ it "reports one out of range" do
239
+ messages.("REM Mon SPECIAL SHADE 256 255 204\n").should ==
240
+ ["SHADE red component 256 is outside 0-255"]
241
+ end
242
+ end
243
+
244
+ describe "ansicolor" do
245
+ it "accepts components in range" do
246
+ lint.("MSG [ansicolor(0, 255, 0)]red[ansicolor(\"\")]\n").should.be.empty
247
+ end
248
+
249
+ it "reports one out of range" do
250
+ messages.("MSG [ansicolor(0, 300, 0)]x\n").should ==
251
+ ["ansicolor green component 300 is outside 0-255"]
252
+ end
253
+
254
+ it "accepts the four-argument background form" do
255
+ lint.("MSG [ansicolor(255, 255, 255)][ansicolor(0, 0, 0, 1)]x\n").should.be.empty
256
+ end
257
+
258
+ it "says nothing about the reset form" do
259
+ lint.(%(MSG [ansicolor("")]\n)).should.be.empty
260
+ end
261
+
262
+ it "reports a negative component" do
263
+ # The lexer emits the minus as its own token, so this only works because
264
+ # the arguments are reassembled rather than picked out by position.
265
+ messages.("MSG [ansicolor(-1, 0, 0)]\n").should ==
266
+ ["ansicolor red component -1 is outside 0-255"]
267
+ end
268
+
269
+ it "says nothing when the components are computed" do
270
+ lint.("MSG [ansicolor(r, g, b)]\n").should.be.empty
271
+ end
272
+
273
+ it "matches the function name case-insensitively" do
274
+ messages.("MSG [ANSICOLOR(300, 0, 0)]\n").length.should == 1
275
+ end
276
+ end
277
+
278
+ describe "what it stays quiet about" do
279
+ it "says nothing about a SPECIAL it does not know" do
280
+ lint.("SPECIAL HTMLCLASS 999 999 999\n").should.be.empty
281
+ end
282
+
283
+ it "says nothing about ordinary numbers on a line" do
284
+ lint.("MSG 300 400 500 items\n").should.be.empty
285
+ end
286
+
287
+ it "says nothing about comments" do
288
+ lint.("# SPECIAL COLOR 999 0 0 example\n").should.be.empty
289
+ end
290
+ end
291
+
292
+ it "reports at error severity" do
293
+ lint.("SPECIAL COLOR 300 0 0 x\n").first.severity.should == "error"
294
+ end
295
+ end