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,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "source"
4
+
5
+ module RemLint
6
+ # One Remind command, after backslash continuations have been joined.
7
+ #
8
+ # `text` is byte-for-byte what Remind's own LineBuffer would hold: each
9
+ # continuing backslash replaced by a newline and the following line appended
10
+ # verbatim (src/files.c ReadLineFromFile). Keeping the newline rather than
11
+ # collapsing to a space costs nothing -- Remind's tokeniser treats it as
12
+ # whitespace either way -- and buys exact column arithmetic, because every
13
+ # character of every continued line still sits where it sat in the file.
14
+ #
15
+ # `line` is the physical line the command starts on, in the enclosing file;
16
+ # `physical_lines` lists every line it spans; `raw` keeps the untouched bytes,
17
+ # because the whitespace rules care about what the join threw away.
18
+ LogicalLine = Struct.new(
19
+ :text,
20
+ :line,
21
+ :physical_lines,
22
+ :raw,
23
+ keyword_init: true,
24
+ ) do
25
+ def continued?
26
+ physical_lines.length > 1
27
+ end
28
+
29
+ def last_line
30
+ physical_lines.last
31
+ end
32
+
33
+ # The command on one line, for a message or for a rule that would rather
34
+ # not think about continuations at all.
35
+ def squashed
36
+ text.tr("\n", " ")
37
+ end
38
+
39
+ # Map a character offset in `text` back to a physical [line, column] in the
40
+ # file. Column is 1-based, so `position_at(0)` is the first column of the
41
+ # line the command starts on.
42
+ def position_at(offset)
43
+ before = text[0, offset].to_s
44
+ crossed = before.count("\n")
45
+ last_break = before.rindex("\n")
46
+
47
+ if last_break
48
+ column = offset - last_break
49
+ else
50
+ column = offset + 1
51
+ end
52
+
53
+ [physical_lines.fetch(crossed, last_line), column]
54
+ end
55
+ end
56
+
57
+ # Joins backslash-continued physical lines into logical ones.
58
+ #
59
+ # This is where a whole bug class lives. Remind continues a line only on a
60
+ # backslash that is the very last character; a backslash followed by a space
61
+ # is just a backslash, and the long `IIF(...)` chains in `defs.rem` and
62
+ # `astro` are held together by nothing else. Joining here -- rather than
63
+ # letting each rule guess -- means every rule sees the command Remind sees,
64
+ # and "backslash that does not continue" becomes something DanglingContinuation
65
+ # can report rather than something that silently splits a command in half.
66
+ module Joiner
67
+ module_function
68
+
69
+ def call(source)
70
+ buffer = nil
71
+
72
+ [].tap do |logical|
73
+ source.lines.each_with_index do |raw, index|
74
+ line_no = index + 1 + source.line_offset
75
+ body = raw.chomp
76
+
77
+ if buffer
78
+ buffer.text << body
79
+ buffer.physical_lines << line_no
80
+ buffer.raw << raw
81
+ else
82
+ buffer = LogicalLine.new(
83
+ text: +body,
84
+ line: line_no,
85
+ physical_lines: [line_no],
86
+ raw: +raw,
87
+ )
88
+ end
89
+
90
+ if buffer.text.end_with?("\\")
91
+ # Remind overwrites the backslash with the newline it just consumed.
92
+ buffer.text[-1] = "\n"
93
+ else
94
+ logical << buffer
95
+ buffer = nil
96
+ end
97
+ end
98
+
99
+ # A file whose last line ends in a backslash leaves a command open.
100
+ # Emit it anyway, so the rules can see -- and complain about -- it.
101
+ if buffer
102
+ logical << buffer
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
108
+
109
+ __END__
110
+
111
+ describe "line joining" do
112
+ join = proc do |text, line_offset = 0|
113
+ RemLint::Joiner.call(RemLint::Source.new(path: "t.rem", text: text, line_offset: line_offset))
114
+ end
115
+
116
+ describe "RemLint::Joiner" do
117
+ it "leaves ordinary lines alone" do
118
+ lines = join.("REM 1 Jan MSG New Year\nREM 2 Jan MSG Day two\n")
119
+
120
+ lines.length.should == 2
121
+ lines.map(&:text).should == ["REM 1 Jan MSG New Year", "REM 2 Jan MSG Day two"]
122
+ lines.map(&:line).should == [1, 2]
123
+ end
124
+
125
+ it "joins a continued command the way Remind's LineBuffer does" do
126
+ lines = join.("SET x IIF(a, \\\n b, \\\n c)\n")
127
+
128
+ lines.length.should == 1
129
+ lines.first.text.should == "SET x IIF(a, \n b, \n c)"
130
+ lines.first.squashed.should == "SET x IIF(a, b, c)"
131
+ end
132
+
133
+ it "records every physical line a joined command spans" do
134
+ line = join.("SET x 1 + \\\n2 + \\\n3\n").first
135
+
136
+ line.line.should == 1
137
+ line.last_line.should == 3
138
+ line.physical_lines.should == [1, 2, 3]
139
+ line.should.be.continued
140
+ end
141
+
142
+ it "keeps the raw bytes of every joined line" do
143
+ join.("SET x 1 + \\\n 2\n").first.raw.should == "SET x 1 + \\\n 2\n"
144
+ end
145
+
146
+ it "does not join when the backslash is followed by whitespace" do
147
+ # Remind continues only on a backslash in the final column. This is the
148
+ # bug DanglingContinuation exists to report.
149
+ lines = join.("SET x 1 + \\ \nSET y 2\n")
150
+
151
+ lines.length.should == 2
152
+ lines.first.text.should == "SET x 1 + \\ "
153
+ end
154
+
155
+ it "emits a command left open at end of file" do
156
+ lines = join.("SET x 1 + \\\n")
157
+
158
+ lines.length.should == 1
159
+ lines.first.text.should == "SET x 1 + \n"
160
+ lines.first.raw.should.match(/\\\n\z/)
161
+ end
162
+
163
+ it "shifts every line number by the source's offset" do
164
+ join.("MSG one\nMSG two\n", 40).map(&:line).should == [41, 42]
165
+ end
166
+
167
+ it "handles an empty source" do
168
+ join.("").should.be.empty
169
+ end
170
+ end
171
+
172
+ describe "RemLint::LogicalLine#position_at" do
173
+ it "reports a 1-based column on an unjoined line" do
174
+ line = join.("SET x 12345\n").first
175
+
176
+ line.position_at(0).should == [1, 1]
177
+ line.position_at(4).should == [1, 5]
178
+ end
179
+
180
+ it "walks onto the physical line the offset actually landed on" do
181
+ line = join.("SET x 1 + \\\n 22 + \\\n 333\n").first
182
+
183
+ # The joined text is "SET x 1 + \n 22 + \n 333".
184
+ line.position_at(line.text.index("22")).should == [2, 5]
185
+ line.position_at(line.text.index("333")).should == [3, 5]
186
+ end
187
+
188
+ it "carries the source offset into reported positions" do
189
+ line = join.("SET x 1 + \\\n 22\n", 40).first
190
+
191
+ line.position_at(line.text.index("22")).should == [42, 5]
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RemLint
4
+ # Ordered worst-first, so a run that reports only the top N reports the N
5
+ # that matter.
6
+ #
7
+ # Defined out here rather than inside the Struct body below: a constant
8
+ # assigned inside a block belongs to the lexical scope around the block, not
9
+ # to the class the block configures, so `Offense::SEVERITIES` would not have
10
+ # existed.
11
+ SEVERITIES = %w[error warning info].freeze
12
+
13
+ # One thing a rule found, at one place in one file.
14
+ #
15
+ # The default rendering is `path:line:column: severity: [Rule] message`,
16
+ # which vim's default `errorformat` reads, which GitHub Actions' problem
17
+ # matchers read, and which `sort` orders correctly. The format is also
18
+ # configurable, following puppet-lint, because the one thing every CI system
19
+ # agrees on is that it wants a slightly different format.
20
+ Offense = Struct.new(
21
+ :path,
22
+ :line,
23
+ :column,
24
+ :severity,
25
+ :rule,
26
+ :message,
27
+ keyword_init: true,
28
+ ) do
29
+ def initialize(path:, line:, rule:, message:, column: 1, severity: "warning")
30
+ super
31
+ end
32
+
33
+ def severity_rank
34
+ SEVERITIES.index(severity) || SEVERITIES.length
35
+ end
36
+
37
+ def error?
38
+ severity == "error"
39
+ end
40
+
41
+ # At or above the given severity, where "above" means closer to error.
42
+ def at_least?(level)
43
+ severity_rank <= (SEVERITIES.index(level) || SEVERITIES.length)
44
+ end
45
+
46
+ def to_s
47
+ format_with("%{path}:%{line}:%{column}: %{severity}: [%{rule}] %{message}")
48
+ end
49
+
50
+ # `%{path}`, `%{line}`, `%{column}`, `%{severity}`, `%{rule}`, `%{message}`.
51
+ def format_with(template)
52
+ format(
53
+ template,
54
+ path: path,
55
+ line: line,
56
+ column: column,
57
+ severity: severity,
58
+ rule: rule,
59
+ message: message,
60
+ )
61
+ end
62
+
63
+ # File, then position, then severity: the order someone reads them in.
64
+ def sort_key
65
+ [path, line, column, severity_rank, rule]
66
+ end
67
+ end
68
+ end
69
+
70
+ __END__
71
+
72
+ describe "RemLint::Offense" do
73
+ offense = proc do |overrides = {}|
74
+ defaults = {
75
+ path: "holidays.rem",
76
+ line: 12,
77
+ column: 4,
78
+ rule: "TrailingWhitespace",
79
+ message: "Trailing whitespace",
80
+ }
81
+
82
+ RemLint::Offense.new(**defaults.merge(overrides))
83
+ end
84
+
85
+ it "renders in a format vim's errorformat reads" do
86
+ offense.().to_s.should == "holidays.rem:12:4: warning: [TrailingWhitespace] Trailing whitespace"
87
+ end
88
+
89
+ it "defaults to column 1 and warning severity" do
90
+ bare = RemLint::Offense.new(path: "a.rem", line: 1, rule: "R", message: "m")
91
+
92
+ bare.column.should == 1
93
+ bare.severity.should == "warning"
94
+ end
95
+
96
+ it "renders through a caller-supplied template" do
97
+ offense.().format_with("%{line}: %{message}").should == "12: Trailing whitespace"
98
+ end
99
+
100
+ it "knows whether it meets a severity threshold" do
101
+ offense.(severity: "error").should.be.error
102
+ offense.(severity: "error").at_least?("warning").should.be.true
103
+ offense.(severity: "warning").at_least?("error").should.be.false
104
+ offense.(severity: "info").at_least?("warning").should.be.false
105
+ end
106
+
107
+ it "sorts by file, then position, then severity" do
108
+ unsorted = [
109
+ offense.(line: 12, column: 9),
110
+ offense.(line: 3),
111
+ offense.(path: "a.rem", line: 99),
112
+ ]
113
+
114
+ unsorted.sort_by(&:sort_key).map { |o| [o.path, o.line, o.column] }.should ==
115
+ [["a.rem", 99, 4], ["holidays.rem", 3, 4], ["holidays.rem", 12, 9]]
116
+ end
117
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "offense"
4
+
5
+ module RemLint
6
+ # Base class for rules. Subclassing registers.
7
+ #
8
+ # A rule implements `check` and calls `offend` for each thing it finds. It
9
+ # gets one {Document} and its own slice of configuration, and it is
10
+ # instantiated fresh per document, so a rule accumulating state across a file
11
+ # (block stacks, FSET definitions) does not have to clean up after itself.
12
+ #
13
+ # Registry-by-inheritance rather than an explicit list, following haml-lint:
14
+ # a new file under `rules/` that is required is a rule that runs, with
15
+ # nothing else to remember to edit.
16
+ class Rule
17
+ # Guarded, not plain: `scampi` runs each file's co-located specs by
18
+ # `load`ing the file, which re-executes its top level. An unguarded
19
+ # `REGISTRY = []` would empty the registry halfway through a run and take
20
+ # every already-registered rule with it -- the subclasses are not
21
+ # re-registered, because reopening a class does not call `inherited` again.
22
+ unless defined?(REGISTRY)
23
+ REGISTRY = []
24
+ end
25
+
26
+ class << self
27
+ def inherited(subclass)
28
+ super
29
+ REGISTRY << subclass
30
+ end
31
+
32
+ # `RemLint::Rules::TrailingWhitespace` configures and reports as
33
+ # `TrailingWhitespace`. Anonymous subclasses -- which specs make, and
34
+ # which land in the registry like any other -- get a placeholder rather
35
+ # than blowing up every caller that walks the registry.
36
+ def rule_name
37
+ if name
38
+ name.split("::").last
39
+ else
40
+ "(anonymous)"
41
+ end
42
+ end
43
+
44
+ # Overridden by a rule that should be off unless asked for. Rules that
45
+ # enforce a house preference rather than report a defect default to off,
46
+ # because a linter whose first run is a wall of style noise gets turned
47
+ # off entirely.
48
+ def enabled_by_default?
49
+ true
50
+ end
51
+
52
+ def default_severity
53
+ "warning"
54
+ end
55
+
56
+ # One line, shown by `remlint --show-rules` and used as the doc comment
57
+ # in the generated default config.
58
+ def description
59
+ ""
60
+ end
61
+
62
+ # Only named subclasses. A rule with no name cannot be configured, named
63
+ # in a `remlint:disable` comment, or reported usefully, so it is not one
64
+ # the runner can run -- which is also what keeps the anonymous classes a
65
+ # spec makes from turning up in someone else's lint run.
66
+ def all
67
+ REGISTRY.reject { |rule| rule.name.nil? }
68
+ end
69
+
70
+ def find(rule_name)
71
+ all.find { |rule| rule.rule_name == rule_name }
72
+ end
73
+ end
74
+
75
+ attr_reader :config, :document, :offenses
76
+
77
+ def initialize(config = {})
78
+ @config = config
79
+ @offenses = []
80
+ end
81
+
82
+ def run(document)
83
+ @document = document
84
+ @offenses = []
85
+ check
86
+ offenses
87
+ end
88
+
89
+ def rule_name
90
+ self.class.rule_name
91
+ end
92
+
93
+ # Subclasses implement this.
94
+ def check
95
+ raise NotImplementedError, "#{self.class} must implement #check"
96
+ end
97
+
98
+ private
99
+
100
+ # `severity:` overrides the configured level for one offence. Only one
101
+ # rule needs it: Syntax, which relays diagnostics Remind has already
102
+ # graded for itself and should not promote a warning to an error.
103
+ def offend(line, message, column: 1, severity: nil)
104
+ @offenses << Offense.new(
105
+ path: document.path,
106
+ line: line,
107
+ column: column,
108
+ severity: severity || configured_severity,
109
+ rule: rule_name,
110
+ message: message,
111
+ )
112
+ end
113
+
114
+ # Report at a character offset into a logical line, letting the line work
115
+ # out which physical line and column that lands on. This is what a rule
116
+ # working from the token stream wants: it keeps offences inside a
117
+ # continued command pointing at the continuation they are actually on.
118
+ def offend_at(logical_line, offset, message)
119
+ line, column = logical_line.position_at(offset)
120
+
121
+ offend(line, message, column: column)
122
+ end
123
+
124
+ def configured_severity
125
+ config.fetch("Severity", self.class.default_severity)
126
+ end
127
+
128
+ def option(key, default)
129
+ config.fetch(key, default)
130
+ end
131
+ end
132
+ end
133
+
134
+ __END__
135
+
136
+ require_relative "document"
137
+ require_relative "rules/trailing_whitespace"
138
+
139
+ describe "RemLint::Rule" do
140
+ # Defined inside the spec so the registry assertions have something of their
141
+ # own to find, rather than depending on which real rules happen to be loaded.
142
+ example = Class.new(RemLint::Rule) do
143
+ def self.rule_name = "ExampleRule"
144
+
145
+ def check
146
+ document.code_commands.each do |command|
147
+ offend(command.line, "saw #{command.keyword&.name}", column: command.keyword_column)
148
+ end
149
+ end
150
+ end
151
+
152
+ document = proc do |text|
153
+ RemLint::Document.new(RemLint::Source.new(path: "t.rem", text: text))
154
+ end
155
+
156
+ it "registers every subclass" do
157
+ RemLint::Rule::REGISTRY.should.include example
158
+ end
159
+
160
+ it "keeps anonymous subclasses out of the runnable set" do
161
+ # A rule with no name cannot be configured or reported by name, and the
162
+ # ones a spec makes must not leak into anyone else's run.
163
+ RemLint::Rule.all.should.not.include example
164
+ end
165
+
166
+ it "collects the offences a check reports" do
167
+ offenses = example.new.run(document.("# note\nMSG hi\n"))
168
+
169
+ offenses.length.should == 1
170
+ offenses.first.line.should == 2
171
+ offenses.first.message.should == "saw MSG"
172
+ offenses.first.rule.should == "ExampleRule"
173
+ end
174
+
175
+ it "starts empty on each run rather than accumulating" do
176
+ rule = example.new
177
+ rule.run(document.("MSG hi\n"))
178
+ rule.run(document.("MSG hi\n"))
179
+
180
+ rule.offenses.length.should == 1
181
+ end
182
+
183
+ it "takes severity from configuration, falling back to the rule's default" do
184
+ example.new.run(document.("MSG hi\n")).first.severity.should == "warning"
185
+ example.new("Severity" => "error").run(document.("MSG hi\n")).first.severity.should == "error"
186
+ end
187
+
188
+ it "insists a subclass implements check" do
189
+ incomplete = Class.new(RemLint::Rule)
190
+
191
+ lambda { incomplete.new.run(document.("MSG hi\n")) }.should.raise NotImplementedError
192
+ end
193
+
194
+ it "finds a named rule and nothing else" do
195
+ RemLint::Rule.find("TrailingWhitespace").rule_name.should == "TrailingWhitespace"
196
+ RemLint::Rule.find("ExampleRule").should.be.nil
197
+ RemLint::Rule.find("NoSuchRule").should.be.nil
198
+ end
199
+
200
+ it "reports an offset inside a continued command on the right physical line" do
201
+ positional = Class.new(RemLint::Rule) do
202
+ def self.rule_name = "Positional"
203
+
204
+ def check
205
+ line = document.logical_lines.first
206
+
207
+ offend_at(line, line.text.index("boom"), "found it")
208
+ end
209
+ end
210
+
211
+ offense = positional.new.run(document.("SET x 1 + \\\n boom\n")).first
212
+
213
+ offense.line.should == 2
214
+ offense.column.should == 5
215
+ end
216
+ end