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,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rule"
|
|
4
|
+
|
|
5
|
+
module RemLint
|
|
6
|
+
module Rules
|
|
7
|
+
# `INFO` clauses a back-end will drop.
|
|
8
|
+
#
|
|
9
|
+
# `INFO` attaches a `"Header: value"` pair to a reminder, which the
|
|
10
|
+
# machine-readable back-ends carry through and `%<Header>` reads back. Two
|
|
11
|
+
# things go wrong, and a back-end's only recourse for either is to ignore
|
|
12
|
+
# the clause:
|
|
13
|
+
#
|
|
14
|
+
# INFO with no quoted "Header: value" string
|
|
15
|
+
# two INFO clauses on one command with the same header
|
|
16
|
+
#
|
|
17
|
+
# Headers are not case-sensitive, so `Url:` and `URL:` on one command are
|
|
18
|
+
# the same collision -- which is exactly the sort of duplicate that reads
|
|
19
|
+
# as two distinct pieces of information.
|
|
20
|
+
#
|
|
21
|
+
# The reminder still triggers either way. What goes missing is the location
|
|
22
|
+
# or the URL, silently, in the calendar app at the far end of the export.
|
|
23
|
+
class InfoClause < Rule
|
|
24
|
+
# `"Header: value"` -- the header runs to the first colon and cannot
|
|
25
|
+
# contain a quote.
|
|
26
|
+
WELL_FORMED = /\A(?<quote>["'])(?<header>[^:"']+):(?<value>.*)\k<quote>\z/m
|
|
27
|
+
|
|
28
|
+
def self.default_severity
|
|
29
|
+
"warning"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.description
|
|
33
|
+
"An INFO clause that is malformed, or duplicates another's header."
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def check
|
|
37
|
+
document.code_commands.each do |command|
|
|
38
|
+
check_command(command)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def check_command(command)
|
|
45
|
+
seen = {}
|
|
46
|
+
|
|
47
|
+
document.trigger_for(command).clauses.each do |clause|
|
|
48
|
+
if clause.name == "INFO"
|
|
49
|
+
check_clause(command, clause, seen)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def check_clause(command, clause, seen)
|
|
55
|
+
argument = argument_of(command, clause)
|
|
56
|
+
match = argument&.match(WELL_FORMED)
|
|
57
|
+
|
|
58
|
+
if match.nil?
|
|
59
|
+
report_malformed(command, clause, argument)
|
|
60
|
+
else
|
|
61
|
+
check_duplicate(
|
|
62
|
+
command,
|
|
63
|
+
clause,
|
|
64
|
+
match[:header].strip,
|
|
65
|
+
seen,
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The whole quoted string after the keyword, if there is one.
|
|
71
|
+
def argument_of(command, clause)
|
|
72
|
+
tokens = document.tokens_for(command.logical_line)
|
|
73
|
+
token = tokens[clause.argument_index]
|
|
74
|
+
|
|
75
|
+
if token&.type == :string
|
|
76
|
+
token.value
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def report_malformed(command, clause, argument)
|
|
81
|
+
offend_at(
|
|
82
|
+
command.logical_line,
|
|
83
|
+
clause.offset,
|
|
84
|
+
"`INFO` takes a quoted \"Header: value\" string; " \
|
|
85
|
+
"#{argument ? "`#{argument}`" : 'this'} is not one",
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def check_duplicate(command, clause, header, seen)
|
|
90
|
+
key = header.downcase
|
|
91
|
+
first = seen[key]
|
|
92
|
+
|
|
93
|
+
if first
|
|
94
|
+
offend_at(
|
|
95
|
+
command.logical_line,
|
|
96
|
+
clause.offset,
|
|
97
|
+
"`#{header}:` repeats the header given on this command as `#{first}:`; " \
|
|
98
|
+
"headers are not case-sensitive and cannot be duplicated",
|
|
99
|
+
)
|
|
100
|
+
else
|
|
101
|
+
seen[key] = header
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
__END__
|
|
109
|
+
|
|
110
|
+
require_relative "../document"
|
|
111
|
+
|
|
112
|
+
describe "RemLint::Rules::InfoClause" do
|
|
113
|
+
lint = proc do |text|
|
|
114
|
+
source = RemLint::Source.new(path: "t.rem", text: text)
|
|
115
|
+
|
|
116
|
+
RemLint::Rules::InfoClause.new.run(RemLint::Document.new(source))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
messages = proc { |text| lint.(text).map(&:message) }
|
|
120
|
+
|
|
121
|
+
describe "clauses that are fine" do
|
|
122
|
+
it "accepts a well-formed INFO" do
|
|
123
|
+
lint.(%(REM 1 Jan INFO "Location: The pub" MSG Meet up\n)).should.be.empty
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it "accepts two INFO clauses with different headers" do
|
|
127
|
+
lint.(%(REM 1 Jan INFO "Location: pub" INFO "Url: x" MSG hi\n)).should.be.empty
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
it "accepts a value containing a colon" do
|
|
131
|
+
lint.(%(REM 1 Jan INFO "Url: https://example.com/x" MSG hi\n)).should.be.empty
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it "accepts a reminder with no INFO at all" do
|
|
135
|
+
lint.("REM 1 Jan MSG hi\n").should.be.empty
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
describe "malformed clauses" do
|
|
140
|
+
it "reports an unquoted argument" do
|
|
141
|
+
messages.("REM 1 Jan INFO Location: pub MSG hi\n").first.should.match(
|
|
142
|
+
/takes a quoted "Header: value" string/,
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
it "reports a quoted string with no colon" do
|
|
147
|
+
messages.(%(REM 1 Jan INFO "Location" MSG hi\n)).length.should == 1
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it "quotes what was written back" do
|
|
151
|
+
messages.(%(REM 1 Jan INFO "Location" MSG hi\n)).first.should.match(/`"Location"` is not one/)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
describe "duplicate headers" do
|
|
156
|
+
it "reports the same header twice" do
|
|
157
|
+
messages.(%(REM 1 Jan INFO "Url: a" INFO "Url: b" MSG hi\n)).first.should ==
|
|
158
|
+
"`Url:` repeats the header given on this command as `Url:`; " \
|
|
159
|
+
"headers are not case-sensitive and cannot be duplicated"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it "reports a header that differs only in case" do
|
|
163
|
+
messages.(%(REM 1 Jan INFO "Url: a" INFO "URL: b" MSG hi\n)).first.should.match(
|
|
164
|
+
/`URL:` repeats the header given on this command as `Url:`/,
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
it "reports only the second of two" do
|
|
169
|
+
lint.(%(REM 1 Jan INFO "Url: a" INFO "Url: b" MSG hi\n)).length.should == 1
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
it "does not carry headers between commands" do
|
|
173
|
+
text = %(REM 1 Jan INFO "Url: a" MSG hi\nREM 2 Jan INFO "Url: b" MSG hi\n)
|
|
174
|
+
|
|
175
|
+
lint.(text).should.be.empty
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
it "says nothing about comments" do
|
|
180
|
+
lint.(%(# REM 1 Jan INFO "Url: a" INFO "Url: b" MSG hi\n)).should.be.empty
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
it "reports at warning severity" do
|
|
184
|
+
lint.(%(REM 1 Jan INFO "Location" MSG hi\n)).first.severity.should == "warning"
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rule"
|
|
4
|
+
|
|
5
|
+
module RemLint
|
|
6
|
+
module Rules
|
|
7
|
+
# `%<Header>` with no matching `INFO` on the same command.
|
|
8
|
+
#
|
|
9
|
+
# `FindTrigInfo` returns NULL and `dosubst.c` ships nothing at all, so the
|
|
10
|
+
# substitution expands to the empty string. The reminder then reads
|
|
11
|
+
# `Meeting at ` and looks like a truncation bug somewhere else entirely.
|
|
12
|
+
#
|
|
13
|
+
# `INFO` headers are per-command, so this is decidable from one line: the
|
|
14
|
+
# headers a command carries and the headers its body asks for are both
|
|
15
|
+
# right there. Matching is case-insensitive, because Remind's header
|
|
16
|
+
# lookup is.
|
|
17
|
+
class InfoSubstitutionWithoutHeader < Rule
|
|
18
|
+
# `INFO "Header: value"` -- the argument is a quoted string whose header
|
|
19
|
+
# runs up to the first colon.
|
|
20
|
+
INFO_HEADER = /\A["'](?<header>[^:"']+):/
|
|
21
|
+
|
|
22
|
+
REFERENCE = /%<(?<header>[^>]*)>/
|
|
23
|
+
|
|
24
|
+
def self.default_severity
|
|
25
|
+
"warning"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.description
|
|
29
|
+
"A %<Header> substitution with no INFO clause of that name on the command."
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def check
|
|
33
|
+
document.code_commands.each do |command|
|
|
34
|
+
check_command(command)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def check_command(command)
|
|
41
|
+
trigger = document.trigger_for(command)
|
|
42
|
+
|
|
43
|
+
if trigger.text_body?
|
|
44
|
+
report(command, headers(command, trigger))
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The headers this command declares, downcased for comparison.
|
|
49
|
+
def headers(command, trigger)
|
|
50
|
+
trigger.clauses.filter_map do |clause|
|
|
51
|
+
if clause.name == "INFO"
|
|
52
|
+
header_of(command, clause)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The header name in the string that follows the INFO keyword.
|
|
58
|
+
def header_of(command, clause)
|
|
59
|
+
rest = command.text[clause.end_offset..].to_s
|
|
60
|
+
match = rest.sub(/\A\s+/, "").match(INFO_HEADER)
|
|
61
|
+
|
|
62
|
+
match && match[:header].strip.downcase
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def report(command, declared)
|
|
66
|
+
trigger = document.trigger_for(command)
|
|
67
|
+
body = command.text[trigger.body_offset..].to_s
|
|
68
|
+
|
|
69
|
+
body.to_enum(:scan, REFERENCE).each do
|
|
70
|
+
match = Regexp.last_match
|
|
71
|
+
|
|
72
|
+
unless declared.include?(match[:header].strip.downcase)
|
|
73
|
+
offend_at(command.logical_line, trigger.body_offset + match.begin(0), message(match))
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def message(match)
|
|
79
|
+
"`%<#{match[:header]}>` has no matching `INFO` clause on this command; " \
|
|
80
|
+
"it expands to nothing"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
__END__
|
|
87
|
+
|
|
88
|
+
require_relative "../document"
|
|
89
|
+
|
|
90
|
+
describe "RemLint::Rules::InfoSubstitutionWithoutHeader" do
|
|
91
|
+
lint = proc do |text|
|
|
92
|
+
source = RemLint::Source.new(path: "t.rem", text: text)
|
|
93
|
+
|
|
94
|
+
RemLint::Rules::InfoSubstitutionWithoutHeader.new.run(RemLint::Document.new(source))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
messages = proc { |text| lint.(text).map(&:message) }
|
|
98
|
+
|
|
99
|
+
describe "references that match" do
|
|
100
|
+
it "accepts a header the command declares" do
|
|
101
|
+
lint.(%(REM 1 Jan INFO "Location: The pub" MSG Meet at %<Location>\n)).should.be.empty
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it "matches case-insensitively, as Remind's lookup does" do
|
|
105
|
+
lint.(%(REM 1 Jan INFO "Location: pub" MSG Meet at %<LOCATION>\n)).should.be.empty
|
|
106
|
+
lint.(%(REM 1 Jan INFO "URL: x" MSG See %<Url>\n)).should.be.empty
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it "accepts several headers on one command" do
|
|
110
|
+
text = %(REM 1 Jan INFO "Location: pub" INFO "Url: x" MSG %<Location> %<Url>\n)
|
|
111
|
+
|
|
112
|
+
lint.(text).should.be.empty
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it "accepts a header declared after the reference is written" do
|
|
116
|
+
# Clause order does not matter; both are on the same command.
|
|
117
|
+
lint.(%(REM 1 Jan INFO "Location: pub" MSG at %<Location>\n)).should.be.empty
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
describe "references that do not match" do
|
|
122
|
+
it "reports a header the command never declares" do
|
|
123
|
+
messages.("REM 1 Jan MSG Meet at %<Location>\n").first.should ==
|
|
124
|
+
"`%<Location>` has no matching `INFO` clause on this command; it expands to nothing"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it "reports a misspelled header" do
|
|
128
|
+
messages.(%(REM 1 Jan INFO "Location: pub" MSG at %<Locaton>\n)).length.should == 1
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it "reports each unmatched reference" do
|
|
132
|
+
messages.("REM 1 Jan MSG %<A> and %<B>\n").length.should == 2
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
it "does not confuse headers between two commands" do
|
|
136
|
+
text = %(REM 1 Jan INFO "Location: pub" MSG at %<Location>\nREM 2 Jan MSG at %<Location>\n)
|
|
137
|
+
|
|
138
|
+
lint.(text).map(&:line).should == [2]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it "points at the reference" do
|
|
142
|
+
text = "REM 1 Jan MSG Meet at %<Location>\n"
|
|
143
|
+
|
|
144
|
+
lint.(text).first.column.should == text.index("%<") + 1
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
describe "what it leaves alone" do
|
|
149
|
+
it "says nothing about a body with no reference" do
|
|
150
|
+
lint.(%(REM 1 Jan INFO "Location: pub" MSG Meet up\n)).should.be.empty
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it "says nothing about a command with no body" do
|
|
154
|
+
lint.("SET a 1\n").should.be.empty
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
it "says nothing about comments" do
|
|
158
|
+
lint.("# uses %<Location> somewhere\n").should.be.empty
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it "reports at warning severity" do
|
|
163
|
+
lint.("REM 1 Jan MSG %<Location>\n").first.severity.should == "warning"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
it "reports the physical line inside a continuation" do
|
|
167
|
+
lint.("REM 1 Jan MSG a \\\n %<Location>\n").first.line.should == 2
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rule"
|
|
4
|
+
|
|
5
|
+
module RemLint
|
|
6
|
+
module Rules
|
|
7
|
+
# Things a file does that its declared invocation will not deliver.
|
|
8
|
+
#
|
|
9
|
+
# Three checks want the command line rather than the file, and a file that
|
|
10
|
+
# declares one gets all three:
|
|
11
|
+
#
|
|
12
|
+
# # remlint:invocation remind -pp -g /path/to/file
|
|
13
|
+
#
|
|
14
|
+
# SORT SPEC. `-g` takes up to four characters, each `a` or `d`. Anything
|
|
15
|
+
# else is silently ignored, so a sort somebody asked for never happens.
|
|
16
|
+
#
|
|
17
|
+
# INFO HEADERS. Plain `-p` does not carry `INFO` to the back-end at all;
|
|
18
|
+
# `-pp` does. A file full of correct `INFO` clauses and a `-p` invocation
|
|
19
|
+
# is a reminder that is fine and plumbing that is not, and nothing reports
|
|
20
|
+
# the gap.
|
|
21
|
+
#
|
|
22
|
+
# TODO IN A CALENDAR. `TODO`'s nagging and its overdue tail exist only in
|
|
23
|
+
# Agenda Mode. In a calendar a `TODO` is an ordinary event, which makes the
|
|
24
|
+
# keyword a lie about what the reminder does.
|
|
25
|
+
#
|
|
26
|
+
# A file with no declaration says nothing about how it is run, so this rule
|
|
27
|
+
# says nothing about it either.
|
|
28
|
+
class InvocationMismatch < Rule
|
|
29
|
+
SORT_CHARACTERS = %w[a d].freeze
|
|
30
|
+
|
|
31
|
+
MAX_SORT = 4
|
|
32
|
+
|
|
33
|
+
# Where a clause was found, for a message to point at.
|
|
34
|
+
Site = Struct.new(:line, :column)
|
|
35
|
+
|
|
36
|
+
def self.default_severity
|
|
37
|
+
"warning"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.description
|
|
41
|
+
"A file whose declared `# remlint:invocation` does not deliver what it uses."
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def check
|
|
45
|
+
invocation = document.invocation
|
|
46
|
+
|
|
47
|
+
if invocation.declared?
|
|
48
|
+
check_sort(invocation)
|
|
49
|
+
check_info(invocation)
|
|
50
|
+
check_todo(invocation)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def check_sort(invocation)
|
|
57
|
+
spec = invocation.sort_spec
|
|
58
|
+
|
|
59
|
+
if spec && !valid_sort?(spec)
|
|
60
|
+
offend(declaration_line, sort_message(spec))
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def valid_sort?(spec)
|
|
65
|
+
spec.length <= MAX_SORT &&
|
|
66
|
+
spec.each_char.all? { |character| SORT_CHARACTERS.include?(character.downcase) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def check_info(invocation)
|
|
70
|
+
clause = first_clause("INFO")
|
|
71
|
+
|
|
72
|
+
if clause && invocation.simple_calendar? && !invocation.carries_info?
|
|
73
|
+
offend(clause.line, info_message, column: clause.column)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def check_todo(invocation)
|
|
78
|
+
clause = first_clause("TODO")
|
|
79
|
+
|
|
80
|
+
if clause && invocation.calendar?
|
|
81
|
+
offend(clause.line, todo_message, column: clause.column)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The first command carrying a clause of that name, as a line and
|
|
86
|
+
# column to point at. `filter_map` rather than an `each` with a
|
|
87
|
+
# `break`, which yields the whole array when nothing matches.
|
|
88
|
+
def first_clause(name)
|
|
89
|
+
document.code_commands.filter_map { |command| site_of(command, name) }.first
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def site_of(command, name)
|
|
93
|
+
found = document.trigger_for(command).find(name)
|
|
94
|
+
|
|
95
|
+
if found
|
|
96
|
+
Site.new(*command.logical_line.position_at(found.offset))
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def declaration_line
|
|
101
|
+
index = document.raw_lines.find_index { |raw| raw.match?(Invocation::DIRECTIVE) }
|
|
102
|
+
|
|
103
|
+
document.line_number_at(index || 0)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def sort_message(spec)
|
|
107
|
+
"`-g#{spec}` is not a sort spec Remind reads -- up to #{MAX_SORT} characters, " \
|
|
108
|
+
"each `a` or `d` -- and the characters it cannot read are ignored, so the sort " \
|
|
109
|
+
"never happens"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def info_message
|
|
113
|
+
"this file uses `INFO`, but its declared invocation passes plain `-p`, which " \
|
|
114
|
+
"does not carry headers to the back-end at all; `-pp` does"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def todo_message
|
|
118
|
+
"`TODO`'s nagging and overdue tail exist only in Agenda Mode, and this file " \
|
|
119
|
+
"declares a calendar invocation -- in a calendar a `TODO` is an ordinary event"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
__END__
|
|
126
|
+
|
|
127
|
+
require_relative "../document"
|
|
128
|
+
|
|
129
|
+
describe "RemLint::Rules::InvocationMismatch" do
|
|
130
|
+
lint = proc do |text|
|
|
131
|
+
source = RemLint::Source.new(path: "t.rem", text: text)
|
|
132
|
+
|
|
133
|
+
RemLint::Rules::InvocationMismatch.new.run(RemLint::Document.new(source))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
messages = proc { |text| lint.(text).map(&:message) }
|
|
137
|
+
|
|
138
|
+
it "says nothing about a file that declares no invocation" do
|
|
139
|
+
lint.(%(REM 1 Jan INFO "Url: x" TODO MSG hi\n)).should.be.empty
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
describe "the sort spec" do
|
|
143
|
+
it "accepts a spec of a and d" do
|
|
144
|
+
lint.("# remlint:invocation remind -gaad\nMSG hi\n").should.be.empty
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
it "accepts a bare -g" do
|
|
148
|
+
lint.("# remlint:invocation remind -g\nMSG hi\n").should.be.empty
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
it "reports a character Remind does not read" do
|
|
152
|
+
messages.("# remlint:invocation remind -gxq\nMSG hi\n").first.should.match(
|
|
153
|
+
/is not a sort spec Remind reads/,
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
it "reports a spec longer than four characters" do
|
|
158
|
+
messages.("# remlint:invocation remind -gaaaaa\nMSG hi\n").length.should == 1
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it "explains that the sort never happens" do
|
|
162
|
+
messages.("# remlint:invocation remind -gx\nMSG hi\n").first.should.match(
|
|
163
|
+
/the sort never happens/,
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
describe "INFO headers" do
|
|
169
|
+
it "reports INFO under a plain -p invocation" do
|
|
170
|
+
text = %(# remlint:invocation remind -p\nREM 1 Jan INFO "Url: x" MSG hi\n)
|
|
171
|
+
|
|
172
|
+
messages.(text).first.should.match(/passes plain `-p`, which does not carry headers/)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
it "accepts INFO under -pp" do
|
|
176
|
+
text = %(# remlint:invocation remind -pp\nREM 1 Jan INFO "Url: x" MSG hi\n)
|
|
177
|
+
|
|
178
|
+
lint.(text).should.be.empty
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
it "says nothing about a file with no INFO" do
|
|
182
|
+
lint.("# remlint:invocation remind -p\nMSG hi\n").should.be.empty
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
it "says nothing under an agenda invocation" do
|
|
186
|
+
text = %(# remlint:invocation remind -q\nREM 1 Jan INFO "Url: x" MSG hi\n)
|
|
187
|
+
|
|
188
|
+
lint.(text).should.be.empty
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
describe "TODO in a calendar" do
|
|
193
|
+
it "reports it" do
|
|
194
|
+
text = "# remlint:invocation remind -c3\nREM 1 TODO MSG File\n"
|
|
195
|
+
|
|
196
|
+
messages.(text).first.should.match(/exist only in Agenda Mode/)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
it "accepts TODO under an agenda invocation" do
|
|
200
|
+
lint.("# remlint:invocation remind -q\nREM 1 TODO MSG File\n").should.be.empty
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
it "says nothing about a file with no TODO" do
|
|
204
|
+
lint.("# remlint:invocation remind -c3\nMSG hi\n").should.be.empty
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
it "points at the TODO clause" do
|
|
208
|
+
text = "# remlint:invocation remind -c3\nREM 1 TODO MSG File\n"
|
|
209
|
+
|
|
210
|
+
lint.(text).first.line.should == 2
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
it "reports every mismatch a file has" do
|
|
215
|
+
text = %(# remlint:invocation remind -p -gx\nREM 1 INFO "Url: x" TODO MSG hi\n)
|
|
216
|
+
|
|
217
|
+
lint.(text).length.should == 3
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
it "reports at warning severity" do
|
|
221
|
+
lint.("# remlint:invocation remind -gx\nMSG hi\n").first.severity.should == "warning"
|
|
222
|
+
end
|
|
223
|
+
end
|