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,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
require_relative "rule"
|
|
7
|
+
|
|
8
|
+
module RemLint
|
|
9
|
+
# Per-rule settings, merged from the shipped defaults and a `.remlint.yml`.
|
|
10
|
+
#
|
|
11
|
+
# Shaped like RuboCop's: a top-level key per rule, `Enabled` and `Severity`
|
|
12
|
+
# understood for every rule, anything else passed through to the rule that
|
|
13
|
+
# asked for it. `Exclude` at the top level drops paths from the run.
|
|
14
|
+
#
|
|
15
|
+
# Merging is per rule rather than per file: a project that sets
|
|
16
|
+
# `TrailingWhitespace: {Severity: error}` keeps every other rule's defaults
|
|
17
|
+
# instead of silently switching them all off.
|
|
18
|
+
class Config
|
|
19
|
+
DEFAULT_PATH = Pathname.new(__dir__).join("../../config/default.yml").cleanpath
|
|
20
|
+
|
|
21
|
+
FILENAME = ".remlint.yml"
|
|
22
|
+
|
|
23
|
+
attr_reader :settings
|
|
24
|
+
|
|
25
|
+
def initialize(settings = {})
|
|
26
|
+
@settings = settings
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
def default
|
|
31
|
+
new(load_yaml(DEFAULT_PATH))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The defaults with a project file merged over them.
|
|
35
|
+
def load_file(path)
|
|
36
|
+
default.merge(load_yaml(Pathname.new(path)))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Walk up from `directory` looking for a `.remlint.yml`, the way every
|
|
40
|
+
# other linter finds its config, so `remlint` works from a subdirectory.
|
|
41
|
+
def discover(directory = Dir.pwd)
|
|
42
|
+
found = ascend(Pathname.new(directory).expand_path)
|
|
43
|
+
|
|
44
|
+
if found
|
|
45
|
+
load_file(found)
|
|
46
|
+
else
|
|
47
|
+
default
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def ascend(directory)
|
|
52
|
+
directory.ascend do |candidate|
|
|
53
|
+
path = candidate.join(FILENAME)
|
|
54
|
+
|
|
55
|
+
if path.file?
|
|
56
|
+
break path
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def load_yaml(path)
|
|
62
|
+
if path.file?
|
|
63
|
+
YAML.safe_load(path.read, permitted_classes: [], aliases: true) || {}
|
|
64
|
+
else
|
|
65
|
+
{}
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def merge(overrides)
|
|
71
|
+
merged = settings.dup
|
|
72
|
+
|
|
73
|
+
overrides.each do |key, value|
|
|
74
|
+
merged[key] = merge_section(settings[key], value)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
Config.new(merged)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def for_rule(rule_name)
|
|
81
|
+
settings.fetch(rule_name, {})
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# A rule runs unless it is switched off, or unless it is one of the
|
|
85
|
+
# opt-in rules and nothing switched it on.
|
|
86
|
+
def enabled?(rule_class)
|
|
87
|
+
section = for_rule(rule_class.rule_name)
|
|
88
|
+
|
|
89
|
+
section.fetch("Enabled", rule_class.enabled_by_default?)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def excluded?(path)
|
|
93
|
+
exclude_patterns.any? do |pattern|
|
|
94
|
+
File.fnmatch?(pattern, path.to_s, File::FNM_PATHNAME | File::FNM_EXTGLOB)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def exclude_patterns
|
|
99
|
+
Array(settings["Exclude"])
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Rules the config leaves running, instantiated with their own section.
|
|
103
|
+
def rules
|
|
104
|
+
Rule.all.select { |rule_class| enabled?(rule_class) }.map do |rule_class|
|
|
105
|
+
rule_class.new(for_rule(rule_class.rule_name))
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# `--only` on the command line: run these and nothing else, regardless of
|
|
110
|
+
# what the file says, but keep each rule's configured options.
|
|
111
|
+
def only(rule_names)
|
|
112
|
+
wanted = Array(rule_names)
|
|
113
|
+
merged = settings.dup
|
|
114
|
+
|
|
115
|
+
Rule.all.each do |rule_class|
|
|
116
|
+
section = merged.fetch(rule_class.rule_name, {}).dup
|
|
117
|
+
section["Enabled"] = wanted.include?(rule_class.rule_name)
|
|
118
|
+
merged[rule_class.rule_name] = section
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
Config.new(merged)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
# A rule's section merges key by key; anything else -- Exclude, scalars --
|
|
127
|
+
# is replaced outright, because a project listing its own excludes means
|
|
128
|
+
# those and not those plus ours.
|
|
129
|
+
def merge_section(existing, override)
|
|
130
|
+
if existing.is_a?(Hash) && override.is_a?(Hash)
|
|
131
|
+
existing.merge(override)
|
|
132
|
+
else
|
|
133
|
+
override
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
__END__
|
|
140
|
+
|
|
141
|
+
require_relative "document"
|
|
142
|
+
require_relative "rules/line_length"
|
|
143
|
+
require_relative "rules/trailing_whitespace"
|
|
144
|
+
|
|
145
|
+
describe "RemLint::Config" do
|
|
146
|
+
# A pair of registered rules to configure, so the assertions do not depend on
|
|
147
|
+
# which real rules happen to be loaded.
|
|
148
|
+
on_by_default = Class.new(RemLint::Rule) do
|
|
149
|
+
def self.rule_name = "SpecOnByDefault"
|
|
150
|
+
|
|
151
|
+
def check = nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
off_by_default = Class.new(RemLint::Rule) do
|
|
155
|
+
def self.rule_name = "SpecOffByDefault"
|
|
156
|
+
def self.enabled_by_default? = false
|
|
157
|
+
|
|
158
|
+
def check = nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
describe "defaults" do
|
|
162
|
+
it "ships a config for every rule that is on by default" do
|
|
163
|
+
RemLint::Config.default.enabled?(on_by_default).should.be.true
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
it "leaves an opt-in rule off until something asks for it" do
|
|
167
|
+
RemLint::Config.default.enabled?(off_by_default).should.be.false
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
describe "merging" do
|
|
172
|
+
config = RemLint::Config.new(
|
|
173
|
+
"SpecOnByDefault" => { "Enabled" => true, "Severity" => "warning" },
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
it "keeps the keys an override does not mention" do
|
|
177
|
+
merged = config.merge("SpecOnByDefault" => { "Severity" => "error" })
|
|
178
|
+
|
|
179
|
+
merged.for_rule("SpecOnByDefault").should == { "Enabled" => true, "Severity" => "error" }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
it "leaves other rules alone" do
|
|
183
|
+
merged = config.merge("SpecOffByDefault" => { "Enabled" => true })
|
|
184
|
+
|
|
185
|
+
merged.enabled?(on_by_default).should.be.true
|
|
186
|
+
merged.enabled?(off_by_default).should.be.true
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
it "replaces a list outright rather than appending to it" do
|
|
190
|
+
merged = config.merge("Exclude" => ["vendor/**/*"])
|
|
191
|
+
|
|
192
|
+
merged.exclude_patterns.should == ["vendor/**/*"]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
it "does not mutate the config it was merged from" do
|
|
196
|
+
config.merge("SpecOnByDefault" => { "Severity" => "error" })
|
|
197
|
+
|
|
198
|
+
config.for_rule("SpecOnByDefault").fetch("Severity").should == "warning"
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
describe "enabling and disabling" do
|
|
203
|
+
it "honours an explicit Enabled: false" do
|
|
204
|
+
config = RemLint::Config.new("SpecOnByDefault" => { "Enabled" => false })
|
|
205
|
+
|
|
206
|
+
config.enabled?(on_by_default).should.be.false
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
it "honours an explicit Enabled: true on an opt-in rule" do
|
|
210
|
+
config = RemLint::Config.new("SpecOffByDefault" => { "Enabled" => true })
|
|
211
|
+
|
|
212
|
+
config.enabled?(off_by_default).should.be.true
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
it "instantiates each enabled rule with its own section" do
|
|
216
|
+
config = RemLint::Config.new("TrailingWhitespace" => { "Severity" => "warning" })
|
|
217
|
+
rule = config.rules.find { |candidate| candidate.rule_name == "TrailingWhitespace" }
|
|
218
|
+
|
|
219
|
+
rule.config.should == { "Severity" => "warning" }
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
describe "only" do
|
|
224
|
+
it "runs the named rules and nothing else" do
|
|
225
|
+
config = RemLint::Config.default.only(["LineLength"])
|
|
226
|
+
|
|
227
|
+
# LineLength is off by default and TrailingWhitespace is on; --only
|
|
228
|
+
# inverts both, which is the whole point of it.
|
|
229
|
+
config.enabled?(RemLint::Rules::LineLength).should.be.true
|
|
230
|
+
config.enabled?(RemLint::Rules::TrailingWhitespace).should.be.false
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
it "keeps the options of the rules it leaves running" do
|
|
234
|
+
config = RemLint::Config
|
|
235
|
+
.new("SpecOffByDefault" => { "Severity" => "error" })
|
|
236
|
+
.only(["SpecOffByDefault"])
|
|
237
|
+
|
|
238
|
+
config.for_rule("SpecOffByDefault").fetch("Severity").should == "error"
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
describe "excludes" do
|
|
243
|
+
config = RemLint::Config.new("Exclude" => ["examples/*.rem", "vendor/**/*"])
|
|
244
|
+
|
|
245
|
+
it "matches a shell pattern against the path" do
|
|
246
|
+
config.excluded?("examples/tflag.rem").should.be.true
|
|
247
|
+
config.excluded?("vendor/a/b/c.rem").should.be.true
|
|
248
|
+
config.excluded?("holidays.rem").should.be.false
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
it "does not let a single-star pattern cross a directory boundary" do
|
|
252
|
+
config.excluded?("examples/nested/a.rem").should.be.false
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "vocabulary"
|
|
4
|
+
|
|
5
|
+
module RemLint
|
|
6
|
+
# A fully-specified date written out in a trigger.
|
|
7
|
+
#
|
|
8
|
+
# Remind accepts the three components in any order -- `1 Jan 2027`,
|
|
9
|
+
# `Jan 1 2027` and `2027 Jan 1` are the same date to `GetFullDate` -- so this
|
|
10
|
+
# collects components rather than matching a shape.
|
|
11
|
+
CalendarDate = Struct.new(:year, :month, :day) do
|
|
12
|
+
# Days from 1990-01-01, the same epoch Remind counts from (`BASE` in
|
|
13
|
+
# src/custom.h.in). Only ever compared against another one of these, so it
|
|
14
|
+
# needs to be monotonic rather than meaningful.
|
|
15
|
+
def to_i
|
|
16
|
+
(year * 10_000) + (month * 100) + day
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def to_s
|
|
20
|
+
format(
|
|
21
|
+
"%04d-%02d-%02d",
|
|
22
|
+
year,
|
|
23
|
+
month,
|
|
24
|
+
day,
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def <=>(other)
|
|
29
|
+
to_i <=> other.to_i
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
include Comparable
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Reads a date out of a run of tokens, the way `GetFullDate` does.
|
|
36
|
+
#
|
|
37
|
+
# The classification is `FindNumericToken`'s (src/token.c): a number from
|
|
38
|
+
# 1990 to 5990 is a year, a number from 1 to 31 is a day, and `YYYY-MM-DD`
|
|
39
|
+
# is a date all by itself. There is no overlap to resolve -- years start
|
|
40
|
+
# above the largest day -- so the components can arrive in any order.
|
|
41
|
+
#
|
|
42
|
+
# Returns nil unless all three components turn up, because a partial date is
|
|
43
|
+
# a different question from a wrong one, and the rules that care about
|
|
44
|
+
# partial dates want to ask it separately.
|
|
45
|
+
module DateLiteral
|
|
46
|
+
EPOCH_YEAR = 1990
|
|
47
|
+
|
|
48
|
+
# `BASE` + `YR_RANGE` from src/custom.h.in.
|
|
49
|
+
LAST_YEAR = EPOCH_YEAR + 4000
|
|
50
|
+
|
|
51
|
+
MAX_DAY = 31
|
|
52
|
+
|
|
53
|
+
SEPARATORS = %w[- /].freeze
|
|
54
|
+
|
|
55
|
+
module_function
|
|
56
|
+
|
|
57
|
+
# Scans forward from `index` and returns [date, tokens consumed]. Consumed
|
|
58
|
+
# is zero when nothing date-shaped was there at all.
|
|
59
|
+
def scan(tokens, index)
|
|
60
|
+
state = { year: nil, month: nil, day: nil }
|
|
61
|
+
cursor = index
|
|
62
|
+
|
|
63
|
+
while cursor < tokens.length
|
|
64
|
+
taken = absorb(tokens, cursor, state)
|
|
65
|
+
|
|
66
|
+
if taken.zero?
|
|
67
|
+
break
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
cursor += taken
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
[complete(state), cursor - index]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The date at `index`, or nil.
|
|
77
|
+
def at(tokens, index)
|
|
78
|
+
scan(tokens, index).first
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def absorb(tokens, cursor, state)
|
|
82
|
+
iso = iso_date(tokens, cursor)
|
|
83
|
+
|
|
84
|
+
if iso
|
|
85
|
+
merge(state, iso)
|
|
86
|
+
5
|
|
87
|
+
else
|
|
88
|
+
component(tokens[cursor], state)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# `2027-01-01` reaches the lexer as five tokens, because a hyphen is an
|
|
93
|
+
# operator everywhere else.
|
|
94
|
+
def iso_date(tokens, cursor)
|
|
95
|
+
window = tokens[cursor, 5]
|
|
96
|
+
|
|
97
|
+
if window&.length == 5 && iso_shape?(window)
|
|
98
|
+
{ year: window[0].value.to_i, month: window[2].value.to_i, day: window[4].value.to_i }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def iso_shape?(window)
|
|
103
|
+
window[0].type == :number && window[2].type == :number && window[4].type == :number &&
|
|
104
|
+
SEPARATORS.include?(window[1].value) && window[1].value == window[3].value &&
|
|
105
|
+
window[0].value.length == 4
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def component(token, state)
|
|
109
|
+
value = classify(token)
|
|
110
|
+
|
|
111
|
+
if value.nil? || !state[value.first].nil?
|
|
112
|
+
0
|
|
113
|
+
else
|
|
114
|
+
state[value.first] = value.last
|
|
115
|
+
1
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def classify(token)
|
|
120
|
+
case token.type
|
|
121
|
+
when :number then number(token.value)
|
|
122
|
+
when :name then month(token.value)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def number(text)
|
|
127
|
+
value = Integer(text, exception: false)
|
|
128
|
+
|
|
129
|
+
if value.nil?
|
|
130
|
+
nil
|
|
131
|
+
elsif value >= EPOCH_YEAR && value <= LAST_YEAR
|
|
132
|
+
[:year, value]
|
|
133
|
+
elsif value >= 1 && value <= MAX_DAY
|
|
134
|
+
[:day, value]
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Month names abbreviate like every other keyword: `Jan` is `JANUARY`
|
|
139
|
+
# because its minimum length is 3.
|
|
140
|
+
def month(word)
|
|
141
|
+
keyword = Vocabulary.keyword(word)
|
|
142
|
+
|
|
143
|
+
if keyword&.type == "T_Month"
|
|
144
|
+
[:month, MONTHS.index(keyword.name) + 1]
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
MONTHS = %w[
|
|
149
|
+
JANUARY FEBRUARY MARCH APRIL MAY JUNE
|
|
150
|
+
JULY AUGUST SEPTEMBER OCTOBER NOVEMBER DECEMBER
|
|
151
|
+
].freeze
|
|
152
|
+
|
|
153
|
+
def merge(state, components)
|
|
154
|
+
components.each do |key, value|
|
|
155
|
+
state[key] ||= value
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def complete(state)
|
|
160
|
+
if state.values.none?(&:nil?)
|
|
161
|
+
CalendarDate.new(state[:year], state[:month], state[:day])
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
__END__
|
|
168
|
+
|
|
169
|
+
require_relative "document"
|
|
170
|
+
|
|
171
|
+
describe "RemLint::DateLiteral" do
|
|
172
|
+
tokens = proc do |text|
|
|
173
|
+
source = RemLint::Source.new(path: "t.rem", text: text)
|
|
174
|
+
document = RemLint::Document.new(source)
|
|
175
|
+
|
|
176
|
+
document.tokens_for(document.logical_lines.first)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
date = proc { |text| RemLint::DateLiteral.at(tokens.(text), 0) }
|
|
180
|
+
|
|
181
|
+
describe "the spelled-out form" do
|
|
182
|
+
it "reads day, month, year" do
|
|
183
|
+
date.("1 Jan 2027").to_s.should == "2027-01-01"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
it "reads month, day, year" do
|
|
187
|
+
date.("Jan 1 2027").to_s.should == "2027-01-01"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
it "reads them in any other order, as GetFullDate does" do
|
|
191
|
+
date.("2027 Jan 1").to_s.should == "2027-01-01"
|
|
192
|
+
date.("2027 1 Jan").to_s.should == "2027-01-01"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
it "reads a full month name" do
|
|
196
|
+
date.("15 September 2026").to_s.should == "2026-09-15"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
it "reads an abbreviated month name" do
|
|
200
|
+
date.("15 Sep 2026").to_s.should == "2026-09-15"
|
|
201
|
+
date.("15 sept 2026").to_s.should == "2026-09-15"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
it "reads December, the last month" do
|
|
205
|
+
date.("31 Dec 2026").to_s.should == "2026-12-31"
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
describe "the ISO form" do
|
|
210
|
+
it "reads a hyphenated date" do
|
|
211
|
+
date.("2027-01-01").to_s.should == "2027-01-01"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
it "reads a slashed date" do
|
|
215
|
+
date.("2027/03/15").to_s.should == "2027-03-15"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
it "does not read a mixed separator" do
|
|
219
|
+
date.("2027-03/15").should.be.nil
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
it "does not read a subtraction as a date" do
|
|
223
|
+
date.("2027 - 3").should.be.nil
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
describe "dates it will not complete" do
|
|
228
|
+
it "returns nil for a partial date" do
|
|
229
|
+
date.("1 Jan").should.be.nil
|
|
230
|
+
date.("Jan 2027").should.be.nil
|
|
231
|
+
date.("2027").should.be.nil
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
it "returns nil for nothing date-shaped at all" do
|
|
235
|
+
date.("MSG hello").should.be.nil
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
it "returns nil for a number outside both ranges" do
|
|
239
|
+
# 1900 is neither a year Remind represents nor a day.
|
|
240
|
+
date.("32 Jan 1900").should.be.nil
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
describe "component classification" do
|
|
245
|
+
it "treats 1990 as the first year, not a day" do
|
|
246
|
+
date.("1 Jan 1990").to_s.should == "1990-01-01"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
it "treats 5990 as the last year" do
|
|
250
|
+
date.("1 Jan 5990").to_s.should == "5990-01-01"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
it "treats 31 as a day" do
|
|
254
|
+
date.("31 Jan 2027").to_s.should == "2027-01-31"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
it "stops at a component it already has" do
|
|
258
|
+
# The second month name is not part of this date.
|
|
259
|
+
RemLint::DateLiteral.scan(tokens.("1 Jan 2027 Feb"), 0).last.should == 3
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
describe "scanning from an offset" do
|
|
264
|
+
it "starts where it is told to" do
|
|
265
|
+
stream = tokens.("REM 1 Jan 2027 MSG hi")
|
|
266
|
+
|
|
267
|
+
RemLint::DateLiteral.at(stream, 1).to_s.should == "2027-01-01"
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
it "reports how many tokens it took" do
|
|
271
|
+
RemLint::DateLiteral.scan(tokens.("1 Jan 2027"), 0).last.should == 3
|
|
272
|
+
RemLint::DateLiteral.scan(tokens.("2027-01-01"), 0).last.should == 5
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
describe "comparison" do
|
|
277
|
+
it "orders dates" do
|
|
278
|
+
(date.("1 Jan 2027") < date.("2 Jan 2027")).should.be.true
|
|
279
|
+
(date.("31 Dec 2026") < date.("1 Jan 2027")).should.be.true
|
|
280
|
+
(date.("1 Jan 2027") == date.("2027-01-01")).should.be.true
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "source"
|
|
4
|
+
require_relative "logical_line"
|
|
5
|
+
require_relative "command"
|
|
6
|
+
require_relative "expr_lexer"
|
|
7
|
+
require_relative "invocation"
|
|
8
|
+
require_relative "trigger"
|
|
9
|
+
|
|
10
|
+
module RemLint
|
|
11
|
+
# Everything a rule may look at, built once per source and shared by all of
|
|
12
|
+
# them.
|
|
13
|
+
#
|
|
14
|
+
# The four views answer four different questions and rules should take the
|
|
15
|
+
# narrowest one that works:
|
|
16
|
+
#
|
|
17
|
+
# raw_lines the bytes, per physical line -- for whitespace rules
|
|
18
|
+
# logical_lines continuations joined -- for anything spanning lines
|
|
19
|
+
# commands classified -- for anything keyed on which command it is
|
|
20
|
+
# tokens_for lexed -- for anything looking inside a command
|
|
21
|
+
# trigger_for clauses located -- for anything keyed on AT, UNTIL, TZ ...
|
|
22
|
+
#
|
|
23
|
+
# Building all of it eagerly except the token streams keeps rules cheap
|
|
24
|
+
# without lexing files no rule looks inside; the token streams are memoised
|
|
25
|
+
# per logical line, so several rules asking cost one lex.
|
|
26
|
+
class Document
|
|
27
|
+
attr_reader :source, :logical_lines, :commands
|
|
28
|
+
|
|
29
|
+
def initialize(source)
|
|
30
|
+
@source = source
|
|
31
|
+
@logical_lines = Joiner.call(source)
|
|
32
|
+
@commands = Classifier.all(@logical_lines)
|
|
33
|
+
@token_cache = {}
|
|
34
|
+
@trigger_cache = {}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def path
|
|
38
|
+
source.path
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def line_offset
|
|
42
|
+
source.line_offset
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def label
|
|
46
|
+
source.label
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The physical lines, exactly as they are in the file, newline and all.
|
|
50
|
+
def raw_lines
|
|
51
|
+
@raw_lines ||= source.lines
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The file line number of `raw_lines[index]`.
|
|
55
|
+
def line_number_at(index)
|
|
56
|
+
index + 1 + line_offset
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def each_raw_line
|
|
60
|
+
raw_lines.each_with_index do |raw, index|
|
|
61
|
+
yield raw, line_number_at(index)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Significant tokens of one logical line, lexed at most once.
|
|
66
|
+
def tokens_for(logical_line)
|
|
67
|
+
@token_cache[logical_line.line] ||= ExprLexer.significant(logical_line.text)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# How the file says it is meant to be run, from a
|
|
71
|
+
# `# remlint:invocation` comment. Undeclared for a file that says nothing,
|
|
72
|
+
# which is the honest default.
|
|
73
|
+
def invocation
|
|
74
|
+
@invocation ||= Invocation.of(self)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The clauses of one command's trigger, parsed at most once. Rules ask this
|
|
78
|
+
# rather than scanning tokens themselves, because the trigger/body boundary
|
|
79
|
+
# is easy to get wrong and expensive to get wrong twice.
|
|
80
|
+
def trigger_for(command)
|
|
81
|
+
@trigger_cache[command.line] ||= Trigger.of(
|
|
82
|
+
tokens_for(command.logical_line),
|
|
83
|
+
triggered: Trigger.triggered?(command),
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def code_commands
|
|
88
|
+
@code_commands ||= commands.select(&:code?)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
__END__
|
|
94
|
+
|
|
95
|
+
describe "RemLint::Document" do
|
|
96
|
+
document = proc do |text, line_offset = 0|
|
|
97
|
+
RemLint::Document.new(RemLint::Source.new(path: "t.rem", text: text, line_offset: line_offset))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
sample = "# header\nIF a\n MSG [x] \\\n [y]\nENDIF\n"
|
|
101
|
+
|
|
102
|
+
it "exposes the physical lines untouched" do
|
|
103
|
+
document.(sample).raw_lines.length.should == 5
|
|
104
|
+
document.(sample).raw_lines.first.should == "# header\n"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it "exposes logical lines with continuations joined" do
|
|
108
|
+
document.(sample).logical_lines.length.should == 4
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it "exposes classified commands" do
|
|
112
|
+
document.(sample).commands.map(&:kind).should == %i[comment keyword keyword keyword]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it "filters out comments and blanks for rules that only want code" do
|
|
116
|
+
document.("# note\n\nMSG hi\n").code_commands.map(&:line).should == [3]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it "numbers physical lines through the source offset" do
|
|
120
|
+
doc = document.("MSG one\nMSG two\n", 40)
|
|
121
|
+
|
|
122
|
+
doc.line_number_at(0).should == 41
|
|
123
|
+
doc.logical_lines.map(&:line).should == [41, 42]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it "yields each raw line with its file line number" do
|
|
127
|
+
seen = []
|
|
128
|
+
document.("MSG one\nMSG two\n", 40).each_raw_line { |raw, line| seen << [raw.chomp, line] }
|
|
129
|
+
|
|
130
|
+
seen.should == [["MSG one", 41], ["MSG two", 42]]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it "lexes a logical line once and reuses the result" do
|
|
134
|
+
doc = document.("MSG [ansicolor(1,2,3)]\n")
|
|
135
|
+
line = doc.logical_lines.first
|
|
136
|
+
|
|
137
|
+
doc.tokens_for(line).map(&:type).should.include :function
|
|
138
|
+
doc.tokens_for(line).should.be.identical_to doc.tokens_for(line)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it "parses a command's trigger once and reuses the result" do
|
|
142
|
+
doc = document.("REM Tue AT 15:00 MSG Meet Bob at the pub\n")
|
|
143
|
+
command = doc.code_commands.first
|
|
144
|
+
|
|
145
|
+
doc.trigger_for(command).include?("AT").should.be.true
|
|
146
|
+
doc.trigger_for(command).should.be.identical_to doc.trigger_for(command)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it "reads the file's declared invocation" do
|
|
150
|
+
doc = document.("# remlint:invocation remind -pp\nMSG hi\n")
|
|
151
|
+
|
|
152
|
+
doc.invocation.should.be.carries_info
|
|
153
|
+
doc.invocation.should.be.identical_to doc.invocation
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it "carries the source's label for messages" do
|
|
157
|
+
source = RemLint::Source.new(path: "astro", text: "MSG hi\n", description: "heredoc at line 3")
|
|
158
|
+
|
|
159
|
+
RemLint::Document.new(source).label.should == "astro (heredoc at line 3)"
|
|
160
|
+
end
|
|
161
|
+
end
|