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,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ require_relative "config"
6
+ require_relative "formatter"
7
+ require_relative "rules"
8
+ require_relative "runner"
9
+ require_relative "version"
10
+
11
+ module RemLint
12
+ # The `remlint` command.
13
+ #
14
+ # Streams are injected rather than assumed, so the whole command is testable
15
+ # without shelling out or capturing `$stdout`.
16
+ class CLI
17
+ EXIT_SUCCESS = 0
18
+ EXIT_OFFENSES = 1
19
+ EXIT_USAGE = 2
20
+
21
+ DEFAULT_PATHS = ["."].freeze
22
+
23
+ Options = Struct.new(
24
+ :paths,
25
+ :config_path,
26
+ :only,
27
+ :format,
28
+ :fail_level,
29
+ keyword_init: true,
30
+ )
31
+
32
+ attr_reader :out, :err
33
+
34
+ def initialize(out: $stdout, err: $stderr)
35
+ @out = out
36
+ @err = err
37
+ end
38
+
39
+ def call(argv)
40
+ options = Options.new(
41
+ paths: [],
42
+ config_path: nil,
43
+ only: nil,
44
+ format: Formatter::DEFAULT_TEMPLATE,
45
+ fail_level: "warning",
46
+ )
47
+
48
+ dispatch(parse(argv, options), options)
49
+ rescue OptionParser::ParseError => error
50
+ err.puts(error.message)
51
+ EXIT_USAGE
52
+ end
53
+
54
+ private
55
+
56
+ # `parse` returns a symbol for the flags that do something instead of
57
+ # linting, and nil for an ordinary run.
58
+ def dispatch(action, options)
59
+ case action
60
+ when :version then print_version
61
+ when :help then EXIT_SUCCESS
62
+ when :show_rules then print_rules
63
+ when :show_config then print_config(options)
64
+ else lint(options)
65
+ end
66
+ end
67
+
68
+ def parse(argv, options)
69
+ action = nil
70
+
71
+ parser(options) { |chosen| action = chosen }.parse!(argv)
72
+ options.paths = paths_from(argv)
73
+
74
+ action
75
+ end
76
+
77
+ # No paths means the working directory, the way every other linter reads
78
+ # a bare invocation.
79
+ def paths_from(argv)
80
+ if argv.empty?
81
+ DEFAULT_PATHS.dup
82
+ else
83
+ argv
84
+ end
85
+ end
86
+
87
+ def parser(options)
88
+ OptionParser.new do |parser|
89
+ parser.banner = "Usage: remlint [options] [path...]"
90
+
91
+ parser.on("-c", "--config PATH", "Configuration file (default: nearest .remlint.yml)") do |path|
92
+ options.config_path = path
93
+ end
94
+
95
+ parser.on(
96
+ "-o",
97
+ "--only RULES",
98
+ Array,
99
+ "Run only these rules",
100
+ ) do |rules|
101
+ options.only = rules
102
+ end
103
+
104
+ parser.on("-f", "--format TEMPLATE", "Output template with %{path} %{line} %{column} %{severity} %{rule} %{message}") do |template|
105
+ options.format = template
106
+ end
107
+
108
+ parser.on("--fail-level LEVEL", SEVERITIES, "Exit non-zero at this severity or worse (default: warning)") do |level|
109
+ options.fail_level = level
110
+ end
111
+
112
+ parser.on("--show-rules", "List the rules and whether they are on") { yield :show_rules }
113
+ parser.on("--show-config", "Print the configuration in effect") { yield :show_config }
114
+ parser.on("-v", "--version", "Print the version") { yield :version }
115
+
116
+ parser.on("-h", "--help", "Print this message") do
117
+ out.puts(parser.help)
118
+ yield :help
119
+ end
120
+ end
121
+ end
122
+
123
+ def lint(options)
124
+ runner = Runner.new(resolve_config(options))
125
+ offenses = runner.run(options.paths)
126
+
127
+ out.print(Formatter.new(template: options.format).render(offenses))
128
+ runner.warnings.each { |warning| err.puts("remlint: #{warning}") }
129
+
130
+ exit_code(offenses, options.fail_level)
131
+ end
132
+
133
+ def resolve_config(options)
134
+ base = base_config(options)
135
+
136
+ if options.only
137
+ base.only(options.only)
138
+ else
139
+ base
140
+ end
141
+ end
142
+
143
+ def base_config(options)
144
+ if options.config_path
145
+ Config.load_file(options.config_path)
146
+ else
147
+ Config.discover
148
+ end
149
+ end
150
+
151
+ # Offences below the threshold are reported and do not fail the run, so a
152
+ # project can adopt the informational rules without turning its build red.
153
+ def exit_code(offenses, fail_level)
154
+ if offenses.any? { |offense| offense.at_least?(fail_level) }
155
+ EXIT_OFFENSES
156
+ else
157
+ EXIT_SUCCESS
158
+ end
159
+ end
160
+
161
+ def print_version
162
+ out.puts("remlint #{VERSION}")
163
+ EXIT_SUCCESS
164
+ end
165
+
166
+ def print_rules
167
+ config = Config.discover
168
+
169
+ Rule.all.sort_by(&:rule_name).each do |rule|
170
+ if config.enabled?(rule)
171
+ state = "on "
172
+ else
173
+ state = "off"
174
+ end
175
+
176
+ out.puts("#{state} #{rule.rule_name.ljust(26)} #{rule.description}")
177
+ end
178
+
179
+ EXIT_SUCCESS
180
+ end
181
+
182
+ def print_config(options)
183
+ out.puts(YAML.dump(resolve_config(options).settings))
184
+ EXIT_SUCCESS
185
+ end
186
+ end
187
+ end
188
+
189
+ __END__
190
+
191
+ require "stringio"
192
+ require "tempfile"
193
+
194
+ describe "RemLint::CLI" do
195
+ run = proc do |argv|
196
+ out = StringIO.new
197
+ err = StringIO.new
198
+ status = RemLint::CLI.new(out: out, err: err).call(argv)
199
+
200
+ { status: status, out: out.string, err: err.string }
201
+ end
202
+
203
+ # The Tempfile objects are kept, not just their paths: an unreferenced
204
+ # Tempfile is garbage, and its finalizer unlinks the file. In a short run
205
+ # nothing collects and the specs pass; in a long one the file vanishes
206
+ # between being written and being linted.
207
+ fixtures = []
208
+
209
+ fixture = proc do |text|
210
+ file = Tempfile.new(["remlint-cli", ".rem"])
211
+ file.write(text)
212
+ file.close
213
+ fixtures << file
214
+ file.path
215
+ end
216
+
217
+ describe "linting" do
218
+ it "prints nothing and exits zero for a clean file" do
219
+ result = run.([fixture.("MSG hi\n")])
220
+
221
+ result[:status].should == 0
222
+ result[:out].should == ""
223
+ end
224
+
225
+ it "prints each offence and exits non-zero" do
226
+ result = run.(["--only", "TrailingWhitespace", fixture.("MSG hi \n")])
227
+
228
+ result[:status].should == 1
229
+ result[:out].should.match(/: error: \[TrailingWhitespace\] Trailing whitespace/)
230
+ end
231
+
232
+ it "runs only the rules --only names" do
233
+ result = run.(["--only", "LineLength", fixture.("MSG hi \n")])
234
+
235
+ result[:status].should == 0
236
+ end
237
+
238
+ it "honours a custom output format" do
239
+ result = run.(["--only", "TrailingWhitespace", "--format", "%{line}|%{rule}", fixture.("MSG hi \n")])
240
+
241
+ result[:out].should.match(/\A1\|TrailingWhitespace\n/)
242
+ end
243
+ end
244
+
245
+ describe "--fail-level" do
246
+ it "fails on an offence at the threshold" do
247
+ result = run.(["--only", "TrailingWhitespace", "--fail-level", "error", fixture.("MSG hi \n")])
248
+
249
+ result[:status].should == 1
250
+ end
251
+
252
+ it "reports an offence below the threshold without failing on it" do
253
+ path = fixture.("#{'x' * 200}\n")
254
+ result = run.(["--only", "LineLength", "--fail-level", "error", path])
255
+
256
+ result[:status].should == 0
257
+ result[:out].should.match(/\[LineLength\]/)
258
+ end
259
+ end
260
+
261
+ describe "informational flags" do
262
+ it "prints the version" do
263
+ result = run.(["--version"])
264
+
265
+ result[:status].should == 0
266
+ result[:out].should == "remlint #{RemLint::VERSION}\n"
267
+ end
268
+
269
+ it "lists the rules and whether each is on" do
270
+ result = run.(["--show-rules"])
271
+
272
+ result[:status].should == 0
273
+ result[:out].should.match(/^on TrailingWhitespace/)
274
+ end
275
+
276
+ it "prints the configuration in effect" do
277
+ run.(["--show-config"])[:out].should.match(/TrailingWhitespace/)
278
+ end
279
+
280
+ it "prints usage for --help and exits zero" do
281
+ result = run.(["--help"])
282
+
283
+ result[:status].should == 0
284
+ result[:out].should.match(/Usage: remlint/)
285
+ end
286
+ end
287
+
288
+ describe "bad usage" do
289
+ it "reports an unknown flag on stderr and exits two" do
290
+ result = run.(["--nonsense"])
291
+
292
+ result[:status].should == 2
293
+ result[:err].should.match(/invalid option/)
294
+ end
295
+
296
+ it "reports an unknown --fail-level" do
297
+ run.(["--fail-level", "catastrophic"])[:status].should == 2
298
+ end
299
+ end
300
+ end
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "logical_line"
4
+ require_relative "vocabulary"
5
+
6
+ module RemLint
7
+ # A classified logical line: which keyword opens it, and what follows.
8
+ #
9
+ # `keyword` is nil for three different things, and rules must not confuse
10
+ # them, so ask rather than test for nil:
11
+ #
12
+ # blank? nothing but whitespace
13
+ # comment? opens with `#` or `;`
14
+ # implicit? a trigger with the `REM` left off -- `1 Nov ++12 MSG ...`,
15
+ # which the manual explicitly allows and which is the ordinary
16
+ # way to write a reminder
17
+ Command = Struct.new(
18
+ :keyword,
19
+ :word,
20
+ :args,
21
+ :logical_line,
22
+ :kind,
23
+ keyword_init: true,
24
+ ) do
25
+ # Where `args` begins in `text`. `args` is always a suffix of `text`, so a
26
+ # rule that found something at offset N in `args` can hand
27
+ # `args_offset + N` to LogicalLine#position_at and get a real file position.
28
+ def args_offset
29
+ text.length - args.length
30
+ end
31
+
32
+ def line
33
+ logical_line.line
34
+ end
35
+
36
+ def last_line
37
+ logical_line.last_line
38
+ end
39
+
40
+ def text
41
+ logical_line.text
42
+ end
43
+
44
+ def blank?
45
+ kind == :blank
46
+ end
47
+
48
+ def comment?
49
+ kind == :comment
50
+ end
51
+
52
+ # A command line that opens with no recognisable keyword. Remind reads it
53
+ # as a bare trigger with an implicit `REM`.
54
+ def implicit?
55
+ kind == :implicit
56
+ end
57
+
58
+ # Blank lines and comments are not commands at all.
59
+ def code?
60
+ kind == :keyword || kind == :implicit
61
+ end
62
+
63
+ def keyword?(*names)
64
+ !keyword.nil? && names.map(&:upcase).include?(keyword.name)
65
+ end
66
+
67
+ # The column, 1-based, where the keyword itself starts.
68
+ def keyword_column
69
+ (text.index(/\S/) || 0) + 1
70
+ end
71
+ end
72
+
73
+ # Splits a logical line into its opening keyword and the rest.
74
+ #
75
+ # Deliberately shallow: Remind's grammar is loose enough that a full parse
76
+ # would be a second implementation of Remind, and wrong in different places
77
+ # than the first. Rules that need to look inside `args` reach for
78
+ # {ExprLexer}; rules that only care which command this is stop here.
79
+ module Classifier
80
+ COMMENT = /\A\s*[#;]/
81
+ BLANK = /\A\s*\z/m
82
+ WORD = /\A\s*(\S+)/
83
+
84
+ module_function
85
+
86
+ def call(logical_line)
87
+ text = logical_line.text
88
+
89
+ if text.match?(BLANK)
90
+ bare(logical_line, :blank)
91
+ elsif text.match?(COMMENT)
92
+ bare(logical_line, :comment)
93
+ else
94
+ classify_code(logical_line, text)
95
+ end
96
+ end
97
+
98
+ def all(logical_lines)
99
+ logical_lines.map { |logical_line| call(logical_line) }
100
+ end
101
+
102
+ # A line opening with a clause keyword -- a month, a weekday, an ordinal --
103
+ # is a trigger with the `REM` left off, not a command named `JANUARY`. It
104
+ # gets `kind: :implicit` like a line opening with a bare day number, and
105
+ # keeps its whole text as `args`, because the clause is part of the trigger
106
+ # rather than something the command takes.
107
+ def classify_code(logical_line, text)
108
+ match = text.match(WORD)
109
+ word = match[1]
110
+ keyword = Vocabulary.keyword(word)
111
+ commands = !keyword.nil? && !keyword.clause?
112
+
113
+ Command.new(
114
+ keyword: keyword,
115
+ word: word,
116
+ args: commands ? match.post_match.sub(/\A[ \t]+/, "") : text.sub(/\A\s*/, ""),
117
+ logical_line: logical_line,
118
+ kind: commands ? :keyword : :implicit,
119
+ )
120
+ end
121
+
122
+ def bare(logical_line, kind)
123
+ Command.new(
124
+ keyword: nil,
125
+ word: nil,
126
+ args: logical_line.text,
127
+ logical_line: logical_line,
128
+ kind: kind,
129
+ )
130
+ end
131
+ end
132
+ end
133
+
134
+ __END__
135
+
136
+ describe "RemLint::Classifier" do
137
+ classify = proc do |text|
138
+ source = RemLint::Source.new(path: "t.rem", text: text)
139
+
140
+ RemLint::Classifier.all(RemLint::Joiner.call(source))
141
+ end
142
+
143
+ one = proc { |text| classify.(text).first }
144
+
145
+ it "recognises a keyword and splits off its arguments" do
146
+ command = one.("REM 1 Jan MSG New Year")
147
+
148
+ command.keyword.name.should == "REM"
149
+ command.args.should == "1 Jan MSG New Year"
150
+ command.should.be.code
151
+ end
152
+
153
+ it "recognises a keyword whatever its case" do
154
+ one.("msg hello").keyword.name.should == "MSG"
155
+ one.("MsG hello").keyword.name.should == "MSG"
156
+ end
157
+
158
+ it "recognises an abbreviated keyword" do
159
+ one.("INC defs.rem").keyword.name.should == "INCLUDE"
160
+ end
161
+
162
+ it "treats a bare trigger as an implicit REM" do
163
+ command = one.("1 Nov ++12 MSG Get ready")
164
+
165
+ command.keyword.should.be.nil
166
+ command.should.be.implicit
167
+ command.should.be.code
168
+ command.args.should == "1 Nov ++12 MSG Get ready"
169
+ end
170
+
171
+ it "treats a trigger opening with a clause keyword as an implicit REM too" do
172
+ command = one.("Nov 1 MSG All Saints")
173
+
174
+ command.should.be.implicit
175
+ command.keyword.name.should == "NOVEMBER"
176
+ command.args.should == "Nov 1 MSG All Saints"
177
+ end
178
+
179
+ it "marks comments, in both of Remind's comment characters" do
180
+ one.("# a note").should.be.comment
181
+ one.("; also a note").should.be.comment
182
+ one.(" # indented").should.be.comment
183
+ one.("# a note").code?.should.be.false
184
+ end
185
+
186
+ it "marks blank lines" do
187
+ one.("\n").should.be.blank
188
+ one.(" \t \n").should.be.blank
189
+ one.("\n").code?.should.be.false
190
+ end
191
+
192
+ it "can map an offset in args back to a file position" do
193
+ command = one.(" SET x 12345")
194
+
195
+ command.args.should == "x 12345"
196
+ command.logical_line.position_at(command.args_offset).should == [1, 8]
197
+ end
198
+
199
+ it "does not mistake a trailing # for a comment" do
200
+ one.("MSG see issue #42").should.be.code
201
+ end
202
+
203
+ it "keeps the logical line, so continued commands classify once" do
204
+ commands = classify.("IF a > 1 && \\\n b < 2\nENDIF\n")
205
+
206
+ commands.length.should == 2
207
+ commands.first.keyword.name.should == "IF"
208
+ commands.first.line.should == 1
209
+ commands.first.last_line.should == 2
210
+ end
211
+
212
+ it "answers keyword? by canonical name, whatever was written" do
213
+ one.("inc defs.rem").keyword?("INCLUDE").should.be.true
214
+ one.("inc defs.rem").keyword?("MSG", "INCLUDE").should.be.true
215
+ one.("inc defs.rem").keyword?("MSG").should.be.false
216
+ one.("# note").keyword?("MSG").should.be.false
217
+ end
218
+
219
+ it "reports the column the keyword starts in" do
220
+ one.("MSG hi").keyword_column.should == 1
221
+ one.(" MSG hi").keyword_column.should == 5
222
+ end
223
+
224
+ it "classifies a whole file in order" do
225
+ commands = classify.("# header\n\nSET a 1\n1 Jan MSG hi\n")
226
+
227
+ commands.map(&:kind).should == %i[comment blank keyword implicit]
228
+ end
229
+ end