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.
- checksums.yaml +7 -0
- data/LICENSE +328 -0
- data/config/default.yml +374 -0
- data/docs/remlint-rules.md +390 -0
- data/docs/remlint.md +238 -0
- data/exe/remlint +8 -0
- data/lib/remlint/cli.rb +300 -0
- data/lib/remlint/command.rb +229 -0
- data/lib/remlint/config.rb +255 -0
- data/lib/remlint/date_literal.rb +283 -0
- data/lib/remlint/document.rb +161 -0
- data/lib/remlint/expr_lexer.rb +197 -0
- data/lib/remlint/extractors.rb +210 -0
- data/lib/remlint/formatter.rb +96 -0
- data/lib/remlint/invocation.rb +145 -0
- data/lib/remlint/logical_line.rb +194 -0
- data/lib/remlint/offense.rb +117 -0
- data/lib/remlint/rule.rb +216 -0
- data/lib/remlint/rules/addomit_without_scanfrom.rb +189 -0
- data/lib/remlint/rules/advance_warning_body.rb +176 -0
- data/lib/remlint/rules/banner_placement.rb +126 -0
- data/lib/remlint/rules/calendar_text_limited.rb +125 -0
- data/lib/remlint/rules/callback_signature.rb +221 -0
- data/lib/remlint/rules/clause_needs_full_date.rb +176 -0
- data/lib/remlint/rules/clause_requires_at.rb +178 -0
- data/lib/remlint/rules/clause_value_range.rb +257 -0
- data/lib/remlint/rules/color_component_range.rb +295 -0
- data/lib/remlint/rules/coordinate_not_string.rb +175 -0
- data/lib/remlint/rules/dangling_continuation.rb +134 -0
- data/lib/remlint/rules/date_out_of_range.rb +180 -0
- data/lib/remlint/rules/debug_command.rb +189 -0
- data/lib/remlint/rules/easterdate_from_today.rb +160 -0
- data/lib/remlint/rules/function_arity.rb +385 -0
- data/lib/remlint/rules/function_redefinition.rb +159 -0
- data/lib/remlint/rules/generated_file_edited.rb +125 -0
- data/lib/remlint/rules/hebrew_date.rb +245 -0
- data/lib/remlint/rules/iftrig_with_satisfy.rb +101 -0
- data/lib/remlint/rules/include_path.rb +155 -0
- data/lib/remlint/rules/info_clause.rb +186 -0
- data/lib/remlint/rules/info_substitution_without_header.rb +169 -0
- data/lib/remlint/rules/invocation_mismatch.rb +223 -0
- data/lib/remlint/rules/keyword_case.rb +172 -0
- data/lib/remlint/rules/license_header.rb +101 -0
- data/lib/remlint/rules/line_length.rb +101 -0
- data/lib/remlint/rules/literal_type_mismatch.rb +324 -0
- data/lib/remlint/rules/localization_pack.rb +144 -0
- data/lib/remlint/rules/moon_phase_argument.rb +162 -0
- data/lib/remlint/rules/omit_aware_delta.rb +168 -0
- data/lib/remlint/rules/push_vars_missing_name.rb +158 -0
- data/lib/remlint/rules/repeat_trigger.rb +188 -0
- data/lib/remlint/rules/satisfy_constraint.rb +230 -0
- data/lib/remlint/rules/shell_maxlen.rb +170 -0
- data/lib/remlint/rules/shell_use_while_run_disabled.rb +172 -0
- data/lib/remlint/rules/string_escape.rb +158 -0
- data/lib/remlint/rules/syntax.rb +229 -0
- data/lib/remlint/rules/system_variable_assignment.rb +197 -0
- data/lib/remlint/rules/tag_syntax.rb +145 -0
- data/lib/remlint/rules/text_after_eof_marker.rb +134 -0
- data/lib/remlint/rules/time_zone_name.rb +212 -0
- data/lib/remlint/rules/tk_tag_namespace.rb +121 -0
- data/lib/remlint/rules/todo_complete_through.rb +122 -0
- data/lib/remlint/rules/trailing_whitespace.rb +116 -0
- data/lib/remlint/rules/translate_command.rb +203 -0
- data/lib/remlint/rules/unbalanced_blocks.rb +305 -0
- data/lib/remlint/rules/unbalanced_delimiters.rb +251 -0
- data/lib/remlint/rules/unknown_special_type.rb +155 -0
- data/lib/remlint/rules/unknown_substitution_sequence.rb +255 -0
- data/lib/remlint/rules/unknown_system_variable.rb +145 -0
- data/lib/remlint/rules/unquoted_shell_substitution.rb +266 -0
- data/lib/remlint/rules/until_before_from.rb +204 -0
- data/lib/remlint/rules/world_writable_script.rb +128 -0
- data/lib/remlint/rules.rb +60 -0
- data/lib/remlint/runner.rb +274 -0
- data/lib/remlint/source.rb +36 -0
- data/lib/remlint/tables.rb +395 -0
- data/lib/remlint/trigger.rb +359 -0
- data/lib/remlint/version.rb +5 -0
- data/lib/remlint/vocabulary.rb +236 -0
- data/lib/remlint.rb +35 -0
- data/tasks/generate_tables.rb +175 -0
- 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
|