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,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