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