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,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rule"
|
|
4
|
+
|
|
5
|
+
module RemLint
|
|
6
|
+
module Rules
|
|
7
|
+
# Command keywords written in a case the file does not otherwise use.
|
|
8
|
+
#
|
|
9
|
+
# Remind's keywords are case-insensitive -- `remind.vim` opens with
|
|
10
|
+
# `syn case ignore` for exactly this reason -- so this is a consistency
|
|
11
|
+
# rule and nothing more. It is off by default: `examples/alignment.rem`
|
|
12
|
+
# deliberately mixes `MSG` and `msg` to show the two are the same, and a
|
|
13
|
+
# linter that shouts at the shipped examples for their own subject matter
|
|
14
|
+
# is a linter people switch off.
|
|
15
|
+
#
|
|
16
|
+
# `EnforcedStyle: upper` (the default when the rule is on) matches every
|
|
17
|
+
# example Remind ships and the manual's own prose. `lower` is the mirror
|
|
18
|
+
# image. `consistent` picks whichever style the file already uses more and
|
|
19
|
+
# reports the rest, which is the setting for adopting the rule on a file
|
|
20
|
+
# you did not write.
|
|
21
|
+
class KeywordCase < Rule
|
|
22
|
+
STYLES = %w[upper lower consistent].freeze
|
|
23
|
+
|
|
24
|
+
def self.enabled_by_default?
|
|
25
|
+
false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.description
|
|
29
|
+
"Command keywords in a case other than the file's own."
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def check
|
|
33
|
+
style = resolve_style
|
|
34
|
+
|
|
35
|
+
keyword_commands.each do |command|
|
|
36
|
+
if !conforms?(command.word, style)
|
|
37
|
+
offend(
|
|
38
|
+
command.line,
|
|
39
|
+
"Write `#{cased(command.word, style)}` rather than `#{command.word}`",
|
|
40
|
+
column: command.keyword_column,
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def keyword_commands
|
|
49
|
+
document.code_commands.select { |command| command.kind == :keyword }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def resolve_style
|
|
53
|
+
configured = option("EnforcedStyle", "upper")
|
|
54
|
+
|
|
55
|
+
if configured == "consistent"
|
|
56
|
+
majority_style
|
|
57
|
+
else
|
|
58
|
+
configured
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Whichever style already accounts for more of the file's keywords, so
|
|
63
|
+
# turning the rule on reports the minority rather than half the file.
|
|
64
|
+
# A tie goes to upper, which is what Remind's own examples use.
|
|
65
|
+
def majority_style
|
|
66
|
+
words = keyword_commands.map(&:word)
|
|
67
|
+
lower = words.count { |word| word == word.downcase }
|
|
68
|
+
upper = words.count { |word| word == word.upcase }
|
|
69
|
+
|
|
70
|
+
if lower > upper
|
|
71
|
+
"lower"
|
|
72
|
+
else
|
|
73
|
+
"upper"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def conforms?(word, style)
|
|
78
|
+
word == cased(word, style)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def cased(word, style)
|
|
82
|
+
if style == "lower"
|
|
83
|
+
word.downcase
|
|
84
|
+
else
|
|
85
|
+
word.upcase
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
__END__
|
|
93
|
+
|
|
94
|
+
require_relative "../document"
|
|
95
|
+
|
|
96
|
+
describe "RemLint::Rules::KeywordCase" do
|
|
97
|
+
lint = proc do |text, config = {}|
|
|
98
|
+
source = RemLint::Source.new(path: "t.rem", text: text)
|
|
99
|
+
|
|
100
|
+
RemLint::Rules::KeywordCase.new(config).run(RemLint::Document.new(source))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
messages = proc { |text, config = {}| lint.(text, config).map(&:message) }
|
|
104
|
+
|
|
105
|
+
it "is off unless the configuration asks for it" do
|
|
106
|
+
RemLint::Rules::KeywordCase.enabled_by_default?.should.be.false
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
describe "the default upper style" do
|
|
110
|
+
it "accepts upper-case keywords" do
|
|
111
|
+
lint.("SET a 1\nMSG hi\n").should.be.empty
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it "reports a lower-case keyword" do
|
|
115
|
+
messages.("msg hi\n").should == ["Write `MSG` rather than `msg`"]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it "reports a mixed-case keyword" do
|
|
119
|
+
messages.("MsG hi\n").should == ["Write `MSG` rather than `MsG`"]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it "reports the abbreviation as written, not expanded" do
|
|
123
|
+
messages.("inc defs.rem\n").should == ["Write `INC` rather than `inc`"]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it "points at the keyword's column" do
|
|
127
|
+
lint.(" msg hi\n").first.column.should == 4
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
describe "the lower style" do
|
|
132
|
+
it "reports an upper-case keyword" do
|
|
133
|
+
messages.("MSG hi\n", "EnforcedStyle" => "lower").should ==
|
|
134
|
+
["Write `msg` rather than `MSG`"]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it "accepts lower-case keywords" do
|
|
138
|
+
lint.("msg hi\n", "EnforcedStyle" => "lower").should.be.empty
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
describe "the consistent style" do
|
|
143
|
+
consistent = { "EnforcedStyle" => "consistent" }
|
|
144
|
+
|
|
145
|
+
it "reports the minority when the file is mostly upper" do
|
|
146
|
+
messages.("MSG a\nMSG b\nmsg c\n", consistent).should == ["Write `MSG` rather than `msg`"]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it "reports the minority when the file is mostly lower" do
|
|
150
|
+
messages.("msg a\nmsg b\nMSG c\n", consistent).should == ["Write `msg` rather than `MSG`"]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it "prefers upper on a tie, as Remind's own examples do" do
|
|
154
|
+
messages.("MSG a\nmsg b\n", consistent).should == ["Write `MSG` rather than `msg`"]
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
describe "what it stays quiet about" do
|
|
159
|
+
it "says nothing about a trigger with no keyword" do
|
|
160
|
+
lint.("1 Nov ++12 MSG get ready\n").should.be.empty
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
it "says nothing about clause keywords inside a trigger" do
|
|
164
|
+
# Only the command's own keyword is this rule's business.
|
|
165
|
+
lint.("REM 1 Jan until 3 Jan MSG hi\n").should.be.empty
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
it "says nothing about comments or blank lines" do
|
|
169
|
+
lint.("# msg\n\n").should.be.empty
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rule"
|
|
4
|
+
|
|
5
|
+
module RemLint
|
|
6
|
+
module Rules
|
|
7
|
+
# Files missing a licence identifier near the top.
|
|
8
|
+
#
|
|
9
|
+
# Off by default, because it is a project policy rather than a Remind fact.
|
|
10
|
+
# It exists because it is a policy Remind itself keeps: every file under
|
|
11
|
+
# `examples/` carries `SPDX-License-Identifier: GPL-2.0-only` in its header
|
|
12
|
+
# comment, and a linter is the only thing that keeps that true as files are
|
|
13
|
+
# added.
|
|
14
|
+
#
|
|
15
|
+
# `Pattern` is a regular expression as a string, so a project with a
|
|
16
|
+
# different licence or a different marker configures it rather than
|
|
17
|
+
# forking the rule. Only the first `WithinLines` lines are searched, so a
|
|
18
|
+
# mention halfway down a long file does not count as a header.
|
|
19
|
+
class LicenseHeader < Rule
|
|
20
|
+
DEFAULT_PATTERN = "SPDX-License-Identifier:\\s*\\S+"
|
|
21
|
+
DEFAULT_WITHIN = 10
|
|
22
|
+
|
|
23
|
+
def self.enabled_by_default?
|
|
24
|
+
false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.description
|
|
28
|
+
"A licence identifier in the first few lines of the file."
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def check
|
|
32
|
+
within = option("WithinLines", DEFAULT_WITHIN)
|
|
33
|
+
pattern = Regexp.new(option("Pattern", DEFAULT_PATTERN))
|
|
34
|
+
|
|
35
|
+
if !document.raw_lines.empty? && !header_matches?(pattern, within)
|
|
36
|
+
offend(document.line_number_at(0), "Missing a licence identifier in the first #{within} lines")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def header_matches?(pattern, within)
|
|
43
|
+
document.raw_lines.first(within).any? { |raw| raw.match?(pattern) }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
__END__
|
|
50
|
+
|
|
51
|
+
require_relative "../document"
|
|
52
|
+
|
|
53
|
+
describe "RemLint::Rules::LicenseHeader" do
|
|
54
|
+
lint = proc do |text, config = {}|
|
|
55
|
+
source = RemLint::Source.new(path: "t.rem", text: text)
|
|
56
|
+
|
|
57
|
+
RemLint::Rules::LicenseHeader.new(config).run(RemLint::Document.new(source))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it "is off unless the configuration asks for it" do
|
|
61
|
+
RemLint::Rules::LicenseHeader.enabled_by_default?.should.be.false
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it "accepts the header Remind's own examples carry" do
|
|
65
|
+
lint.("# Demo\n# SPDX-License-Identifier: GPL-2.0-only\nMSG hi\n").should.be.empty
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it "reports a file with no identifier" do
|
|
69
|
+
offenses = lint.("# Demo\nMSG hi\n")
|
|
70
|
+
|
|
71
|
+
offenses.length.should == 1
|
|
72
|
+
offenses.first.line.should == 1
|
|
73
|
+
offenses.first.message.should.match(/licence identifier/)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it "does not count an identifier below the header" do
|
|
77
|
+
body = "MSG hi\n" * 12
|
|
78
|
+
|
|
79
|
+
lint.("#{body}# SPDX-License-Identifier: GPL-2.0-only\n").length.should == 1
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it "honours a wider WithinLines" do
|
|
83
|
+
body = "MSG hi\n" * 12
|
|
84
|
+
|
|
85
|
+
lint.("#{body}# SPDX-License-Identifier: GPL-2.0-only\n", "WithinLines" => 20).should.be.empty
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it "honours a project's own pattern" do
|
|
89
|
+
lint.("# Copyright 2026 Someone\n", "Pattern" => "Copyright \\d{4}").should.be.empty
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it "says nothing about an empty file" do
|
|
93
|
+
lint.("").should.be.empty
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
it "reports a heredoc at its position in the enclosing file" do
|
|
97
|
+
source = RemLint::Source.new(path: "astro", text: "MSG hi\n", line_offset: 12)
|
|
98
|
+
|
|
99
|
+
RemLint::Rules::LicenseHeader.new.run(RemLint::Document.new(source)).first.line.should == 13
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rule"
|
|
4
|
+
|
|
5
|
+
module RemLint
|
|
6
|
+
module Rules
|
|
7
|
+
# Physical lines past a configured width.
|
|
8
|
+
#
|
|
9
|
+
# Off by default, and measured on physical lines rather than joined ones,
|
|
10
|
+
# because the whole point of a backslash continuation is to keep a long
|
|
11
|
+
# command inside the margin -- reporting the joined length would report the
|
|
12
|
+
# very files that did the right thing.
|
|
13
|
+
#
|
|
14
|
+
# Remind has its own limit, `$MaxLineLength`, but that is a runtime guard
|
|
15
|
+
# against a runaway `INCLUDECMD`, not a style setting; this is the style
|
|
16
|
+
# setting.
|
|
17
|
+
class LineLength < Rule
|
|
18
|
+
DEFAULT_MAXIMUM = 100
|
|
19
|
+
|
|
20
|
+
def self.enabled_by_default?
|
|
21
|
+
false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.default_severity
|
|
25
|
+
"info"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.description
|
|
29
|
+
"Physical lines longer than Max characters."
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def check
|
|
33
|
+
maximum = option("Max", DEFAULT_MAXIMUM)
|
|
34
|
+
|
|
35
|
+
document.each_raw_line do |raw, line|
|
|
36
|
+
length = raw.chomp.length
|
|
37
|
+
|
|
38
|
+
if length > maximum
|
|
39
|
+
offend(line, "Line is #{length} characters, over the #{maximum} allowed", column: maximum + 1)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
__END__
|
|
48
|
+
|
|
49
|
+
require_relative "../document"
|
|
50
|
+
|
|
51
|
+
describe "RemLint::Rules::LineLength" do
|
|
52
|
+
lint = proc do |text, config = {}|
|
|
53
|
+
source = RemLint::Source.new(path: "t.rem", text: text)
|
|
54
|
+
|
|
55
|
+
RemLint::Rules::LineLength.new(config).run(RemLint::Document.new(source))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
short = { "Max" => 10 }
|
|
59
|
+
|
|
60
|
+
it "is off unless the configuration asks for it" do
|
|
61
|
+
RemLint::Rules::LineLength.enabled_by_default?.should.be.false
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it "accepts a line at the limit" do
|
|
65
|
+
lint.("MSG 123456\n", short).should.be.empty
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it "reports a line over the limit" do
|
|
69
|
+
offenses = lint.("MSG 1234567\n", short)
|
|
70
|
+
|
|
71
|
+
offenses.length.should == 1
|
|
72
|
+
offenses.first.message.should == "Line is 11 characters, over the 10 allowed"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it "points at the first column past the limit" do
|
|
76
|
+
lint.("MSG 1234567\n", short).first.column.should == 11
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it "measures physical lines, so a continuation is not penalised for the join" do
|
|
80
|
+
lint.("MSG 12345\\\n 67890\n", short).should.be.empty
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it "does not count the newline" do
|
|
84
|
+
lint.("MSG 123456\n", short).should.be.empty
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "reports at info severity by default" do
|
|
88
|
+
lint.("MSG 1234567\n", short).first.severity.should == "info"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it "defaults to a hundred characters" do
|
|
92
|
+
lint.("#{'x' * 101}\n").length.should == 1
|
|
93
|
+
lint.("#{'x' * 100}\n").should.be.empty
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
it "reports lines of a heredoc at their position in the enclosing file" do
|
|
97
|
+
source = RemLint::Source.new(path: "astro", text: "MSG 1234567\n", line_offset: 12)
|
|
98
|
+
|
|
99
|
+
RemLint::Rules::LineLength.new(short).run(RemLint::Document.new(source)).first.line.should == 13
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rule"
|
|
4
|
+
|
|
5
|
+
module RemLint
|
|
6
|
+
module Rules
|
|
7
|
+
# Comparisons between literals of different types.
|
|
8
|
+
#
|
|
9
|
+
# `compare()` in src/expr.c settles both halves of this exactly:
|
|
10
|
+
#
|
|
11
|
+
# if (v1.type != v2.type) {
|
|
12
|
+
# if (how == EQ) ans->v.val = 0;
|
|
13
|
+
# else if (how == NE) ans->v.val = 1;
|
|
14
|
+
# else return E_BAD_TYPE;
|
|
15
|
+
# }
|
|
16
|
+
#
|
|
17
|
+
# So `"foo" == 14:33` is not a comparison that happens to be false today.
|
|
18
|
+
# It is the constant 0, on every date, for ever -- and Remind reports
|
|
19
|
+
# nothing, because as far as it is concerned the answer is simply no. An
|
|
20
|
+
# ordering operator across types at least errors, but only on the day the
|
|
21
|
+
# line is reached.
|
|
22
|
+
#
|
|
23
|
+
# Both operands must be literals for the type to be knowable without
|
|
24
|
+
# running anything, which is the usual case for the mistake: comparing a
|
|
25
|
+
# variable against a constant of the wrong shape.
|
|
26
|
+
class LiteralTypeMismatch < Rule
|
|
27
|
+
CONSTANT = { "==" => "0", "!=" => "1" }.freeze
|
|
28
|
+
ORDERING = %w[< > <= >=].freeze
|
|
29
|
+
|
|
30
|
+
OPERATORS = (CONSTANT.keys + ORDERING).freeze
|
|
31
|
+
|
|
32
|
+
# E_BAD_TYPE's text, from src/err.h.
|
|
33
|
+
BAD_TYPE = "Type mismatch"
|
|
34
|
+
|
|
35
|
+
# `'2027-01-01'`, `'14:33'`, `'2027-01-01@14:33'` -- src/expr.c reads a
|
|
36
|
+
# single-quoted literal as a DATE, a TIME or a DATETIME by its shape.
|
|
37
|
+
DATETIME = /\A'.*@.*'\z/
|
|
38
|
+
DATE = %r{\A'\d{4}[-/]\d{1,2}[-/]\d{1,2}'\z}
|
|
39
|
+
TIME = /\A'\d{1,2}[:.]\d{2}'\z/
|
|
40
|
+
|
|
41
|
+
def self.default_severity
|
|
42
|
+
"error"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.description
|
|
46
|
+
"A comparison between literals of different types."
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def check
|
|
50
|
+
document.logical_lines.each_with_index do |logical_line, index|
|
|
51
|
+
if document.commands[index].code?
|
|
52
|
+
check_line(logical_line)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def check_line(logical_line)
|
|
60
|
+
tokens = document.tokens_for(logical_line)
|
|
61
|
+
|
|
62
|
+
operators(tokens).each do |position, operator, width|
|
|
63
|
+
compare(
|
|
64
|
+
logical_line,
|
|
65
|
+
tokens,
|
|
66
|
+
position,
|
|
67
|
+
operator,
|
|
68
|
+
width,
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The lexer emits each punctuation character separately, so `<=` is two
|
|
74
|
+
# tokens and `==` is two more.
|
|
75
|
+
def operators(tokens)
|
|
76
|
+
tokens.each_index.filter_map do |index|
|
|
77
|
+
two = glyph(tokens, index, 2)
|
|
78
|
+
one = glyph(tokens, index, 1)
|
|
79
|
+
|
|
80
|
+
if OPERATORS.include?(two)
|
|
81
|
+
[index, two, 2]
|
|
82
|
+
elsif OPERATORS.include?(one) && !continues?(tokens, index)
|
|
83
|
+
[index, one, 1]
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def glyph(tokens, index, width)
|
|
89
|
+
window = tokens[index, width]
|
|
90
|
+
|
|
91
|
+
if window&.length == width && window.all? { |token| token.type == :other }
|
|
92
|
+
window.map(&:value).join
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# `<` is an operator; the `<` of `<=` is half of one.
|
|
97
|
+
def continues?(tokens, index)
|
|
98
|
+
tokens[index + 1]&.type == :other && tokens[index + 1].value == "="
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def compare(logical_line, tokens, position, operator, width)
|
|
102
|
+
left = operand_before(tokens, position)
|
|
103
|
+
right = operand_after(tokens, position + width)
|
|
104
|
+
|
|
105
|
+
if left && right && left.fetch(:type) != right.fetch(:type)
|
|
106
|
+
offend_at(logical_line, left.fetch(:offset), message(operator, left, right))
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def message(operator, left, right)
|
|
111
|
+
constant = CONSTANT[operator]
|
|
112
|
+
|
|
113
|
+
if constant
|
|
114
|
+
"`#{left.fetch(:text)} #{operator} #{right.fetch(:text)}` compares " \
|
|
115
|
+
"#{left.fetch(:type)} with #{right.fetch(:type)}; different types are " \
|
|
116
|
+
"never equal, so this is always #{constant}"
|
|
117
|
+
else
|
|
118
|
+
"`#{left.fetch(:text)} #{operator} #{right.fetch(:text)}` orders " \
|
|
119
|
+
"#{left.fetch(:type)} against #{right.fetch(:type)}, which Remind " \
|
|
120
|
+
"rejects with `#{BAD_TYPE}`"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# A literal ending at `index - 1`, provided what precedes it is a
|
|
125
|
+
# boundary rather than more expression.
|
|
126
|
+
def operand_before(tokens, index)
|
|
127
|
+
literal = time_before(tokens, index - 1) || single(tokens, index - 1)
|
|
128
|
+
|
|
129
|
+
if literal && boundary?(tokens[literal.fetch(:first) - 1])
|
|
130
|
+
literal
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def operand_after(tokens, index)
|
|
135
|
+
literal = time_after(tokens, index) || single(tokens, index)
|
|
136
|
+
|
|
137
|
+
if literal && boundary?(tokens[literal.fetch(:last) + 1])
|
|
138
|
+
literal
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# `14:33` arrives as three tokens, and is a TIME rather than two INTs.
|
|
143
|
+
def time_before(tokens, index)
|
|
144
|
+
time(tokens, index - 2)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def time_after(tokens, index)
|
|
148
|
+
time(tokens, index)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def time(tokens, first)
|
|
152
|
+
window = tokens[first, 3]
|
|
153
|
+
|
|
154
|
+
if first >= 0 && window&.length == 3 && time_shape?(window)
|
|
155
|
+
{
|
|
156
|
+
type: "TIME",
|
|
157
|
+
text: window.map(&:value).join,
|
|
158
|
+
offset: window.first.offset,
|
|
159
|
+
first: first,
|
|
160
|
+
last: first + 2,
|
|
161
|
+
}
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def time_shape?(window)
|
|
166
|
+
window[0].type == :number && window[2].type == :number &&
|
|
167
|
+
window[1].type == :other && [":", "."].include?(window[1].value) &&
|
|
168
|
+
window[2].value.length == 2
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def single(tokens, index)
|
|
172
|
+
token = tokens[index]
|
|
173
|
+
type = index >= 0 && token && literal_type(token)
|
|
174
|
+
|
|
175
|
+
if type
|
|
176
|
+
{ type: type, text: token.value, offset: token.offset, first: index, last: index }
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def literal_type(token)
|
|
181
|
+
case token.type
|
|
182
|
+
when :number then "INT"
|
|
183
|
+
when :string then quoted_type(token.value)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def quoted_type(value)
|
|
188
|
+
if value.start_with?('"')
|
|
189
|
+
"STRING"
|
|
190
|
+
else
|
|
191
|
+
dated_type(value)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def dated_type(value)
|
|
196
|
+
if value.match?(DATETIME)
|
|
197
|
+
"DATETIME"
|
|
198
|
+
elsif value.match?(DATE)
|
|
199
|
+
"DATE"
|
|
200
|
+
elsif value.match?(TIME)
|
|
201
|
+
"TIME"
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# An operand stands alone only if what sits beside it opens, closes,
|
|
206
|
+
# separates or compares. Arithmetic is deliberately absent: in
|
|
207
|
+
# `1 + 1 == "foo"` the left side of the comparison is the expression
|
|
208
|
+
# `1 + 1`, not the literal `1`, and its type is not this rule's to
|
|
209
|
+
# guess. The same reasoning excludes a unary minus.
|
|
210
|
+
BOUNDARIES = ["&", "|", "!", "=", "<", ">"].freeze
|
|
211
|
+
|
|
212
|
+
def boundary?(token)
|
|
213
|
+
token.nil? ||
|
|
214
|
+
token.type == :lbracket || token.type == :rbracket ||
|
|
215
|
+
token.type == :lparen || token.type == :rparen ||
|
|
216
|
+
token.type == :comma ||
|
|
217
|
+
(token.type == :other && BOUNDARIES.include?(token.value))
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
__END__
|
|
224
|
+
|
|
225
|
+
require_relative "../document"
|
|
226
|
+
|
|
227
|
+
describe "RemLint::Rules::LiteralTypeMismatch" do
|
|
228
|
+
lint = proc do |text|
|
|
229
|
+
source = RemLint::Source.new(path: "t.rem", text: text)
|
|
230
|
+
|
|
231
|
+
RemLint::Rules::LiteralTypeMismatch.new.run(RemLint::Document.new(source))
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
messages = proc { |text| lint.(text).map(&:message) }
|
|
235
|
+
|
|
236
|
+
describe "equality across types" do
|
|
237
|
+
it "reports a STRING compared with an INT" do
|
|
238
|
+
messages.(%(IF ["foo" == 14]\n)).first.should.match(
|
|
239
|
+
/compares STRING with INT; different types are never equal, so this is always 0/,
|
|
240
|
+
)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
it "reports a STRING compared with a TIME" do
|
|
244
|
+
messages.(%(IF ["foo" == 14:33]\n)).first.should.match(/compares STRING with TIME/)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
it "reports != as always 1" do
|
|
248
|
+
messages.(%(IF ["foo" != 14]\n)).first.should.match(/always 1/)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
it "reports a DATE compared with a STRING" do
|
|
252
|
+
messages.(%(IF ['2027-01-01' == "2027-01-01"]\n)).first.should.match(
|
|
253
|
+
/compares DATE with STRING/,
|
|
254
|
+
)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
it "reports a DATETIME compared with a DATE" do
|
|
258
|
+
messages.(%(IF ['2027-01-01@14:33' == '2027-01-01']\n)).first.should.match(
|
|
259
|
+
/compares DATETIME with DATE/,
|
|
260
|
+
)
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
describe "ordering across types" do
|
|
265
|
+
it "reports it as the error Remind raises" do
|
|
266
|
+
messages.(%(IF ["foo" < 14]\n)).first.should.match(/orders STRING against INT/)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
it "reports the two-character operators" do
|
|
270
|
+
messages.(%(IF ["foo" <= 14]\n)).length.should == 1
|
|
271
|
+
messages.(%(IF ["foo" >= 14]\n)).length.should == 1
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
describe "comparisons that are fine" do
|
|
276
|
+
it "accepts two literals of the same type" do
|
|
277
|
+
lint.(%(IF ["foo" == "bar"]\n)).should.be.empty
|
|
278
|
+
lint.("IF [1 == 2]\n").should.be.empty
|
|
279
|
+
lint.("IF [14:33 == 15:00]\n").should.be.empty
|
|
280
|
+
lint.(%(IF ['2027-01-01' == '2027-01-02']\n)).should.be.empty
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
it "accepts an ordering between two of the same type" do
|
|
284
|
+
lint.("IF [1 < 2]\n").should.be.empty
|
|
285
|
+
lint.(%(IF ["a" < "b"]\n)).should.be.empty
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
describe "operands it cannot type" do
|
|
290
|
+
it "says nothing when one side is a variable" do
|
|
291
|
+
lint.(%(IF [x == "foo"]\n)).should.be.empty
|
|
292
|
+
lint.(%(IF [$Tw == 5]\n)).should.be.empty
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
it "says nothing when one side is a call" do
|
|
296
|
+
lint.(%(IF [version() == "06.02.10"]\n)).should.be.empty
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
it "says nothing when one side is an arithmetic expression" do
|
|
300
|
+
# `1 + 1` is not a literal, so its type is not knowable here.
|
|
301
|
+
lint.(%(IF [1 + 1 == "foo"]\n)).should.be.empty
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
it "says nothing about a single-quoted literal it cannot classify" do
|
|
305
|
+
lint.(%(IF ['nonsense' == 14]\n)).should.be.empty
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
it "checks a SATISFY expression too" do
|
|
310
|
+
messages.(%(REM 13 SATISFY ["foo" == 14]\n)).length.should == 1
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
it "says nothing about comments" do
|
|
314
|
+
lint.(%(# IF ["foo" == 14]\n)).should.be.empty
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
it "reports at error severity" do
|
|
318
|
+
lint.(%(IF ["foo" == 14]\n)).first.severity.should == "error"
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
it "reports the physical line inside a continuation" do
|
|
322
|
+
lint.(%(IF ["foo" \\\n == 14]\n)).first.line.should == 1
|
|
323
|
+
end
|
|
324
|
+
end
|