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,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+ require_relative "../vocabulary"
5
+
6
+ module RemLint
7
+ module Rules
8
+ # `SET` on a system variable that will not take it.
9
+ #
10
+ # Remind's table marks each variable modifiable or not and, for the integer
11
+ # ones, carries the range it will accept (src/var.c SysVarArr). Writing to a
12
+ # read-only variable is `E_CANT_MODIFY`; writing out of range is
13
+ # `E_2LOW`/`E_2HIGH`. Both are decidable from the line alone whenever the
14
+ # value is a literal, which is how these variables are almost always set:
15
+ #
16
+ # SET $CalMode 1 $CalMode reports the mode, it does not set it
17
+ # SET $FormWidth 10 Remind accepts 20 to 500
18
+ #
19
+ # A computed value (`SET $FormWidth columns()`) is left alone: the rule
20
+ # cannot know what it evaluates to, and guessing would be worse than
21
+ # silence.
22
+ class SystemVariableAssignment < Rule
23
+ # `SET $Name value` -- and `UNSET $Name`, which is the same permission
24
+ # question with no value attached.
25
+ ASSIGNMENT = /\A(?<sigil>\$)(?<name>\w+)\s*(?<value>.*)\z/m
26
+
27
+ INTEGER = /\A[+-]?\d+\z/
28
+
29
+ def self.default_severity
30
+ "error"
31
+ end
32
+
33
+ def self.description
34
+ "SET on a read-only system variable, or with a value outside its range."
35
+ end
36
+
37
+ def check
38
+ document.code_commands.each do |command|
39
+ if command.keyword?("SET", "UNSET")
40
+ check_assignment(command)
41
+ end
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def check_assignment(command)
48
+ match = command.args.match(ASSIGNMENT)
49
+ sysvar = match && Vocabulary.sysvar(match[:name])
50
+
51
+ if sysvar
52
+ check_permission(command, sysvar) || check_range(command, sysvar, match[:value])
53
+ end
54
+ end
55
+
56
+ # Returns truthy when it reported, so a variable that cannot be written
57
+ # at all is not also told its value is out of range.
58
+ def check_permission(command, sysvar)
59
+ unless sysvar.modifiable
60
+ offend(
61
+ command.line,
62
+ "`$#{sysvar.name}` is read-only and cannot be #{verb(command)}",
63
+ column: command.keyword_column,
64
+ )
65
+ end
66
+ end
67
+
68
+ def verb(command)
69
+ if command.keyword?("UNSET")
70
+ "unset"
71
+ else
72
+ "set"
73
+ end
74
+ end
75
+
76
+ def check_range(command, sysvar, value)
77
+ literal = integer_literal(value)
78
+
79
+ if sysvar.bounded? && !literal.nil? && !sysvar.in_range?(literal)
80
+ offend(
81
+ command.line,
82
+ "`$#{sysvar.name}` accepts #{sysvar.min} to #{sysvar.max}, given #{literal}",
83
+ column: command.keyword_column,
84
+ )
85
+ end
86
+ end
87
+
88
+ def integer_literal(value)
89
+ text = value.to_s.strip
90
+
91
+ if text.match?(INTEGER)
92
+ Integer(text)
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+
99
+ __END__
100
+
101
+ require_relative "../document"
102
+
103
+ describe "RemLint::Rules::SystemVariableAssignment" do
104
+ lint = proc do |text|
105
+ source = RemLint::Source.new(path: "t.rem", text: text)
106
+
107
+ RemLint::Rules::SystemVariableAssignment.new.run(RemLint::Document.new(source))
108
+ end
109
+
110
+ messages = proc { |text| lint.(text).map(&:message) }
111
+
112
+ describe "assignments Remind accepts" do
113
+ it "accepts a writable variable" do
114
+ lint.("SET $AddBlankLines 0\n").should.be.empty
115
+ end
116
+
117
+ it "accepts a value at each end of the range" do
118
+ lint.("SET $FormWidth 20\nSET $FormWidth 500\n").should.be.empty
119
+ end
120
+
121
+ it "accepts a negative value inside a range that allows one" do
122
+ lint.("SET $MinsFromUTC -300\n").should.be.empty
123
+ end
124
+
125
+ it "accepts a string value for a string variable" do
126
+ lint.(%(SET $Ago "ago"\n)).should.be.empty
127
+ end
128
+
129
+ it "matches the variable name case-insensitively" do
130
+ lint.("SET $formwidth 100\n").should.be.empty
131
+ end
132
+ end
133
+
134
+ describe "read-only variables" do
135
+ it "reports a SET" do
136
+ messages.("SET $CalMode 1\n").should == ["`$CalMode` is read-only and cannot be set"]
137
+ end
138
+
139
+ it "reports an UNSET with the right verb" do
140
+ messages.("UNSET $CalMode\n").should == ["`$CalMode` is read-only and cannot be unset"]
141
+ end
142
+
143
+ it "does not also complain about the value" do
144
+ lint.("SET $CalMode 99999\n").length.should == 1
145
+ end
146
+
147
+ it "points at the keyword" do
148
+ lint.(" SET $CalMode 1\n").first.column.should == 4
149
+ end
150
+ end
151
+
152
+ describe "values outside a variable's range" do
153
+ it "reports one below the minimum" do
154
+ messages.("SET $FormWidth 10\n").should == ["`$FormWidth` accepts 20 to 500, given 10"]
155
+ end
156
+
157
+ it "reports one above the maximum" do
158
+ messages.("SET $FormWidth 900\n").should == ["`$FormWidth` accepts 20 to 500, given 900"]
159
+ end
160
+
161
+ it "reports a boolean variable given something other than 0 or 1" do
162
+ messages.("SET $AddBlankLines 2\n").should == ["`$AddBlankLines` accepts 0 to 1, given 2"]
163
+ end
164
+
165
+ it "reports at error severity" do
166
+ lint.("SET $FormWidth 10\n").first.severity.should == "error"
167
+ end
168
+ end
169
+
170
+ describe "what it stays quiet about" do
171
+ it "says nothing about a computed value it cannot evaluate" do
172
+ lint.("SET $FormWidth columns()\n").should.be.empty
173
+ lint.("SET $FormWidth [x]\n").should.be.empty
174
+ end
175
+
176
+ it "says nothing about a variable with no declared range" do
177
+ lint.("SET $MaxSatIter 999999\n").should.be.empty
178
+ end
179
+
180
+ it "says nothing about an ordinary variable" do
181
+ lint.("SET CalMode 1\n").should.be.empty
182
+ end
183
+
184
+ it "says nothing about a system variable it has never heard of" do
185
+ # UnknownSystemVariable owns that offence.
186
+ lint.("SET $Nope 1\n").should.be.empty
187
+ end
188
+
189
+ it "says nothing about a command that is not SET or UNSET" do
190
+ lint.("MSG $CalMode is [$CalMode]\n").should.be.empty
191
+ end
192
+ end
193
+
194
+ it "reports the line a continued assignment starts on" do
195
+ lint.("SET $FormWidth \\\n 10\n").first.line.should == 1
196
+ end
197
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module RemLint
6
+ module Rules
7
+ # `TAG` values that will not survive the trip to a back-end.
8
+ #
9
+ # Tags are handed to the machine-readable back-ends as a comma-separated
10
+ # list. A comma inside one tag therefore splits it into two tags nobody
11
+ # wrote, and whitespace inside one ends it early. Neither is an error --
12
+ # the reminder triggers, the tag is simply not the tag that was written,
13
+ # and the mistake surfaces in whatever consumes the export.
14
+ class TagSyntax < Rule
15
+ FORBIDDEN = {
16
+ "," => "separates tags in the list back-ends receive",
17
+ " " => "ends the tag",
18
+ "\t" => "ends the tag",
19
+ }.freeze
20
+
21
+ def self.default_severity
22
+ "warning"
23
+ end
24
+
25
+ def self.description
26
+ "A TAG value containing a comma or whitespace."
27
+ end
28
+
29
+ def check
30
+ document.code_commands.each do |command|
31
+ document.trigger_for(command).clauses.each do |clause|
32
+ if clause.name == "TAG"
33
+ check_tag(command, clause)
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def check_tag(command, clause)
42
+ value = quoted_value(command, clause)
43
+
44
+ if value
45
+ report(command, clause, value)
46
+ end
47
+ end
48
+
49
+ # Only a quoted tag can carry a comma or a space in the first place --
50
+ # an unquoted one is already ended by the whitespace, which the
51
+ # tokeniser settles before this rule sees it.
52
+ def quoted_value(command, clause)
53
+ tokens = document.tokens_for(command.logical_line)
54
+ token = tokens[clause.argument_index]
55
+
56
+ if token&.type == :string
57
+ token.value[1..-2]
58
+ end
59
+ end
60
+
61
+ def report(command, clause, value)
62
+ FORBIDDEN.each do |character, effect|
63
+ if value.include?(character)
64
+ offend_at(command.logical_line, clause.offset, message(value, character, effect))
65
+ end
66
+ end
67
+ end
68
+
69
+ def message(value, character, effect)
70
+ if character == "\t"
71
+ named = "a tab"
72
+ else
73
+ named = "`#{character}`"
74
+ end
75
+
76
+ "the tag `#{value}` contains #{named}, which #{effect}"
77
+ end
78
+ end
79
+ end
80
+ end
81
+
82
+ __END__
83
+
84
+ require_relative "../document"
85
+
86
+ describe "RemLint::Rules::TagSyntax" do
87
+ lint = proc do |text|
88
+ source = RemLint::Source.new(path: "t.rem", text: text)
89
+
90
+ RemLint::Rules::TagSyntax.new.run(RemLint::Document.new(source))
91
+ end
92
+
93
+ messages = proc { |text| lint.(text).map(&:message) }
94
+
95
+ it "accepts a plain tag" do
96
+ lint.(%(REM 1 Jan TAG "birthday" MSG hi\n)).should.be.empty
97
+ end
98
+
99
+ it "accepts an unquoted tag" do
100
+ lint.("REM 1 Jan TAG birthday MSG hi\n").should.be.empty
101
+ end
102
+
103
+ it "accepts a tag with a hyphen or an underscore" do
104
+ lint.(%(REM 1 Jan TAG "work-related_thing" MSG hi\n)).should.be.empty
105
+ end
106
+
107
+ it "reports a comma inside a tag" do
108
+ messages.(%(REM 1 Jan TAG "work,home" MSG hi\n)).first.should ==
109
+ "the tag `work,home` contains `,`, which separates tags in the list back-ends receive"
110
+ end
111
+
112
+ it "reports a space inside a tag" do
113
+ messages.(%(REM 1 Jan TAG "work stuff" MSG hi\n)).first.should.match(/contains `.`, which ends the tag/)
114
+ end
115
+
116
+ it "reports a tab inside a tag" do
117
+ messages.(%(REM 1 Jan TAG "work\tstuff" MSG hi\n)).first.should.match(/contains a tab/)
118
+ end
119
+
120
+ it "reports both faults in one tag" do
121
+ lint.(%(REM 1 Jan TAG "a, b" MSG hi\n)).length.should == 2
122
+ end
123
+
124
+ it "points at the clause" do
125
+ text = %(REM 1 Jan TAG "work,home" MSG hi\n)
126
+
127
+ lint.(text).first.column.should == text.index("TAG") + 1
128
+ end
129
+
130
+ it "says nothing about a reminder with no TAG" do
131
+ lint.("REM 1 Jan MSG hi\n").should.be.empty
132
+ end
133
+
134
+ it "does not read a tag out of a message body" do
135
+ lint.(%(REM 1 Jan MSG the tag "a,b" is wrong\n)).should.be.empty
136
+ end
137
+
138
+ it "says nothing about comments" do
139
+ lint.(%(# REM 1 Jan TAG "a,b" MSG hi\n)).should.be.empty
140
+ end
141
+
142
+ it "reports at warning severity" do
143
+ lint.(%(REM 1 Jan TAG "a,b" MSG hi\n)).first.severity.should == "warning"
144
+ end
145
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module RemLint
6
+ module Rules
7
+ # Commands below a line reading `__EOF__`.
8
+ #
9
+ # Remind stops reading there and treats it as end of file
10
+ # (src/files.c: `if (!strcmp(CurLine, "__EOF__"))`). The match is exact --
11
+ # the whole line, no leading or trailing space -- so this is decidable by
12
+ # looking at it.
13
+ #
14
+ # The marker is a debugging convenience: drop it in, and everything below
15
+ # is temporarily switched off. The failure is leaving it in. What follows
16
+ # still looks like live configuration, still gets edited, and never runs
17
+ # again. Nothing in Remind's output distinguishes "this reminder did not
18
+ # trigger today" from "Remind never read this reminder at all".
19
+ class TextAfterEofMarker < Rule
20
+ MARKER = "__EOF__"
21
+
22
+ def self.default_severity
23
+ "warning"
24
+ end
25
+
26
+ def self.description
27
+ "Commands below a __EOF__ marker, which Remind never reads."
28
+ end
29
+
30
+ def check
31
+ marker = marker_line
32
+
33
+ if marker
34
+ report(marker)
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ # The first marker wins; anything below it is unread, including a
41
+ # second marker. `find_index` rather than an `each` with a `break`,
42
+ # which yields the whole array when nothing matches.
43
+ def marker_line
44
+ document.raw_lines.find_index { |raw| raw.chomp == MARKER }
45
+ end
46
+
47
+ def report(index)
48
+ live = document.commands.count do |command|
49
+ command.code? && command.line > document.line_number_at(index)
50
+ end
51
+
52
+ if live.positive?
53
+ offend(
54
+ document.line_number_at(index),
55
+ "`#{MARKER}` ends the file here; the #{live} command#{live == 1 ? '' : 's'} " \
56
+ "below it are never read",
57
+ )
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+
64
+ __END__
65
+
66
+ require_relative "../document"
67
+
68
+ describe "RemLint::Rules::TextAfterEofMarker" do
69
+ lint = proc do |text|
70
+ source = RemLint::Source.new(path: "t.rem", text: text)
71
+
72
+ RemLint::Rules::TextAfterEofMarker.new.run(RemLint::Document.new(source))
73
+ end
74
+
75
+ it "reports commands below the marker" do
76
+ offenses = lint.("MSG live\n__EOF__\nMSG dead\n")
77
+
78
+ offenses.length.should == 1
79
+ offenses.first.line.should == 2
80
+ offenses.first.message.should.match(/never read/)
81
+ end
82
+
83
+ it "counts how many commands are stranded" do
84
+ lint.("__EOF__\nMSG a\nMSG b\nMSG c\n").first.message.should.match(/the 3 commands below/)
85
+ end
86
+
87
+ it "uses the singular for one" do
88
+ lint.("__EOF__\nMSG a\n").first.message.should.match(/the 1 command below/)
89
+ end
90
+
91
+ it "says nothing when the marker is the last line" do
92
+ lint.("MSG live\n__EOF__\n").should.be.empty
93
+ end
94
+
95
+ it "says nothing when only comments and blanks follow" do
96
+ # Dead comments are not a lie about configuration.
97
+ lint.("MSG live\n__EOF__\n# a note\n\n").should.be.empty
98
+ end
99
+
100
+ it "says nothing about a file with no marker" do
101
+ lint.("MSG a\nMSG b\n").should.be.empty
102
+ end
103
+
104
+ describe "the match is exact" do
105
+ it "ignores a marker with leading whitespace" do
106
+ lint.("MSG a\n __EOF__\nMSG b\n").should.be.empty
107
+ end
108
+
109
+ it "ignores a marker with trailing whitespace" do
110
+ lint.("MSG a\n__EOF__ \nMSG b\n").should.be.empty
111
+ end
112
+
113
+ it "ignores a marker with anything else on the line" do
114
+ lint.("MSG a\n__EOF__ stop here\nMSG b\n").should.be.empty
115
+ end
116
+
117
+ it "ignores a lower-case spelling" do
118
+ lint.("MSG a\n__eof__\nMSG b\n").should.be.empty
119
+ end
120
+ end
121
+
122
+ it "takes the first marker when there are two" do
123
+ offenses = lint.("MSG a\n__EOF__\nMSG b\n__EOF__\nMSG c\n")
124
+
125
+ offenses.length.should == 1
126
+ offenses.first.line.should == 2
127
+ end
128
+
129
+ it "reports a heredoc's marker at its position in the enclosing file" do
130
+ source = RemLint::Source.new(path: "astro", text: "__EOF__\nMSG a\n", line_offset: 12)
131
+
132
+ RemLint::Rules::TextAfterEofMarker.new.run(RemLint::Document.new(source)).first.line.should == 13
133
+ end
134
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ require_relative "../rule"
6
+
7
+ module RemLint
8
+ module Rules
9
+ # `TZ` given a zone name the system does not have.
10
+ #
11
+ # Remind hands the name to the C library, which is case-sensitive: on a
12
+ # system with a zone database, `america/toronto` is not `America/Toronto`.
13
+ # An unrecognised name does not error -- the conversion simply produces
14
+ # undefined results, which for a reminder means firing on the wrong day
15
+ # some months from now.
16
+ #
17
+ # This is the one rule that reads something outside the file: the zone
18
+ # database, at `TZDIR` or `/usr/share/zoneinfo`. Where there is no database
19
+ # to read, it reports nothing rather than guessing -- a linter that cannot
20
+ # run on a machine without tzdata is a linter that cannot run in CI.
21
+ #
22
+ # When a name matches a real zone except in case, the message names the
23
+ # zone that was probably meant. That is the whole difference between a
24
+ # rule that shrugs and one that can be acted on.
25
+ class TimeZoneName < Rule
26
+ DEFAULT_DIRECTORIES = ["/usr/share/zoneinfo", "/etc/zoneinfo"].freeze
27
+
28
+ # `TZ ""` restores the local zone and is not a name to look up.
29
+ LOCAL = ['""', "''"].freeze
30
+
31
+ def self.default_severity
32
+ "warning"
33
+ end
34
+
35
+ def self.description
36
+ "A TZ name the zone database does not have, or one that differs only in case."
37
+ end
38
+
39
+ def check
40
+ zones = database
41
+
42
+ unless zones.fetch(:exact).empty?
43
+ check_commands(zones)
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def check_commands(zones)
50
+ document.code_commands.each do |command|
51
+ document.trigger_for(command).clauses.each do |clause|
52
+ if clause.name == "TZ"
53
+ check_clause(command, clause, zones)
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ def check_clause(command, clause, zones)
60
+ name = written_name(command, clause)
61
+
62
+ if name && !zones.fetch(:exact).include?(name)
63
+ report(
64
+ command,
65
+ clause,
66
+ name,
67
+ zones,
68
+ )
69
+ end
70
+ end
71
+
72
+ def written_name(command, clause)
73
+ text = command.text[clause.end_offset..].to_s.strip[/\A\S+/]
74
+
75
+ if text.nil? || LOCAL.include?(text) || text.include?("[")
76
+ nil
77
+ else
78
+ text.delete('"').delete("'")
79
+ end
80
+ end
81
+
82
+ def report(command, clause, name, zones)
83
+ intended = zones.fetch(:folded)[name.downcase]
84
+
85
+ offend_at(command.logical_line, clause.offset, message(name, intended))
86
+ end
87
+
88
+ def message(name, intended)
89
+ if intended
90
+ "`TZ #{name}` differs only in case from `#{intended}`; zone names are " \
91
+ "case-sensitive, and an unrecognised one gives undefined results rather " \
92
+ "than an error"
93
+ else
94
+ "`TZ #{name}` is not in the zone database; an unrecognised name gives " \
95
+ "undefined results rather than an error"
96
+ end
97
+ end
98
+
99
+ # Two indexes, not one. `exact` decides whether a name is real;
100
+ # `folded` says what a wrong-case name was probably meant to be. Using
101
+ # a single hash for both makes `america/toronto` look valid, which is
102
+ # precisely the case this rule exists to catch.
103
+ def database
104
+ @database ||= build(option("Directory", nil))
105
+ end
106
+
107
+ def build(configured)
108
+ root = configured || DEFAULT_DIRECTORIES.find { |path| Dir.exist?(path) }
109
+
110
+ if root.nil?
111
+ { exact: [], folded: {} }
112
+ else
113
+ index(root)
114
+ end
115
+ end
116
+
117
+ def index(root)
118
+ exact = []
119
+ folded = {}
120
+
121
+ Dir.glob("**/*", base: root).each do |entry|
122
+ unless entry.include?(".") || !File.file?(File.join(root, entry))
123
+ exact << entry
124
+ folded[entry.downcase] ||= entry
125
+ end
126
+ end
127
+
128
+ { exact: exact.to_set, folded: folded }
129
+ end
130
+ end
131
+ end
132
+ end
133
+
134
+ __END__
135
+
136
+ require "fileutils"
137
+ require "tmpdir"
138
+
139
+ require_relative "../document"
140
+
141
+ describe "RemLint::Rules::TimeZoneName" do
142
+ # A database of its own, so the specs do not depend on what the machine
143
+ # running them happens to have installed.
144
+ root = Dir.mktmpdir("remlint-zones")
145
+ ["America/Toronto", "Europe/Amsterdam", "UTC"].each do |zone|
146
+ path = File.join(root, zone)
147
+ FileUtils.mkdir_p(File.dirname(path))
148
+ File.write(path, "")
149
+ end
150
+
151
+ lint = proc do |text|
152
+ source = RemLint::Source.new(path: "t.rem", text: text)
153
+
154
+ RemLint::Rules::TimeZoneName.new("Directory" => root).run(RemLint::Document.new(source))
155
+ end
156
+
157
+ messages = proc { |text| lint.(text).map(&:message) }
158
+
159
+ it "accepts a zone the database has" do
160
+ lint.("REM Fri AT 23:30 TZ America/Toronto MSG hi\n").should.be.empty
161
+ lint.("REM Fri AT 23:30 TZ UTC MSG hi\n").should.be.empty
162
+ end
163
+
164
+ it "accepts a quoted zone name" do
165
+ lint.(%(REM Fri AT 23:30 TZ "America/Toronto" MSG hi\n)).should.be.empty
166
+ end
167
+
168
+ it "accepts the empty TZ that restores the local zone" do
169
+ lint.(%(REM Fri AT 23:30 TZ "" MSG hi\n)).should.be.empty
170
+ end
171
+
172
+ it "reports a zone the database does not have" do
173
+ messages.("REM Fri AT 23:30 TZ Mars/Olympus MSG hi\n").first.should ==
174
+ "`TZ Mars/Olympus` is not in the zone database; an unrecognised name gives " \
175
+ "undefined results rather than an error"
176
+ end
177
+
178
+ it "names the zone that was probably meant when only the case differs" do
179
+ messages.("REM Fri AT 23:30 TZ america/toronto MSG hi\n").first.should.match(
180
+ /differs only in case from `America\/Toronto`/,
181
+ )
182
+ end
183
+
184
+ it "says nothing about a computed zone" do
185
+ lint.("REM Fri AT 23:30 TZ [zone()] MSG hi\n").should.be.empty
186
+ end
187
+
188
+ it "says nothing about a reminder with no TZ" do
189
+ lint.("REM Fri AT 23:30 MSG hi\n").should.be.empty
190
+ end
191
+
192
+ it "says nothing about comments" do
193
+ lint.("# REM Fri AT 23:30 TZ Mars/Olympus MSG hi\n").should.be.empty
194
+ end
195
+
196
+ it "reports nothing at all when there is no database to read" do
197
+ source = RemLint::Source.new(path: "t.rem", text: "REM Fri AT 1:00 TZ Nope MSG hi\n")
198
+ rule = RemLint::Rules::TimeZoneName.new("Directory" => "/nonexistent/zoneinfo")
199
+
200
+ rule.run(RemLint::Document.new(source)).should.be.empty
201
+ end
202
+
203
+ it "points at the clause" do
204
+ text = "REM Fri AT 23:30 TZ Mars/Olympus MSG hi\n"
205
+
206
+ lint.(text).first.column.should == text.index("TZ ") + 1
207
+ end
208
+
209
+ it "reports at warning severity" do
210
+ lint.("REM Fri AT 23:30 TZ Mars/Olympus MSG hi\n").first.severity.should == "warning"
211
+ end
212
+ end