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,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+ require_relative "../date_literal"
5
+
6
+ module RemLint
7
+ module Rules
8
+ # A reminder whose window closes before it opens.
9
+ #
10
+ # `FROM` and `SCANFROM` set the date Remind starts scanning from; `UNTIL`
11
+ # (and its synonym `THROUGH`) sets the date the reminder expires. An
12
+ # `UNTIL` strictly before the start describes an empty window, and the
13
+ # reminder can never fire.
14
+ #
15
+ # Unusually for this linter, Remind is not silent here: it warns at parse
16
+ # time, with a different message for each start keyword --
17
+ #
18
+ # Warning: UNTIL/THROUGH date earlier than FROM date
19
+ # Warning: UNTIL/THROUGH date earlier than SCANFROM date
20
+ #
21
+ # -- so the value on offer is smaller than usual: saying it in the editor
22
+ # and in CI rather than only when someone runs the file and reads stderr.
23
+ # Remind's own `tests/test.rem` marks both forms "Diagnosed".
24
+ #
25
+ # Strictly before, not on or before. `FROM 1992-01-06 UNTIL 1992-01-06` is
26
+ # a legal one-day window and fires on the day; Remind's own warning says
27
+ # "earlier than" for the same reason.
28
+ #
29
+ # Both dates have to be fully spelled out for this to be decidable, which
30
+ # is no restriction: `GetFullDate` requires exactly that of both keywords.
31
+ class UntilBeforeFrom < Rule
32
+ # `UNTIL` and `THROUGH` are the same clause -- `ParseUntil` takes both
33
+ # and writes the same field.
34
+ EXPIRY = %w[UNTIL THROUGH].freeze
35
+
36
+ # `FROM` and `SCANFROM` are different clauses that both set where the
37
+ # scan begins, and Remind checks the expiry against either.
38
+ START = %w[FROM SCANFROM].freeze
39
+
40
+ def self.default_severity
41
+ "error"
42
+ end
43
+
44
+ def self.description
45
+ "An UNTIL or THROUGH date earlier than the reminder's FROM or SCANFROM."
46
+ end
47
+
48
+ def check
49
+ document.code_commands.each do |command|
50
+ check_window(command)
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def check_window(command)
57
+ trigger = document.trigger_for(command)
58
+ start = first_clause(trigger, START)
59
+ expiry = first_clause(trigger, EXPIRY)
60
+ from = clause_date(command, start)
61
+ until_date = clause_date(command, expiry)
62
+
63
+ if from && until_date && until_date < from
64
+ report(
65
+ command,
66
+ start,
67
+ expiry,
68
+ from,
69
+ until_date,
70
+ )
71
+ end
72
+ end
73
+
74
+ def first_clause(trigger, names)
75
+ names.filter_map { |name| trigger.find(name) }.first
76
+ end
77
+
78
+ # The date written immediately after a clause keyword.
79
+ def clause_date(command, clause)
80
+ if clause
81
+ tokens = document.tokens_for(command.logical_line)
82
+
83
+ DateLiteral.at(tokens, clause.argument_index)
84
+ end
85
+ end
86
+
87
+ def report(command, start, expiry, from, until_date)
88
+ offend_at(
89
+ command.logical_line,
90
+ expiry.offset,
91
+ "`#{expiry.name} #{until_date}` is earlier than " \
92
+ "`#{start.name} #{from}`, so this reminder can never trigger",
93
+ )
94
+ end
95
+ end
96
+ end
97
+ end
98
+
99
+ __END__
100
+
101
+ require_relative "../document"
102
+
103
+ describe "RemLint::Rules::UntilBeforeFrom" do
104
+ lint = proc do |text|
105
+ source = RemLint::Source.new(path: "t.rem", text: text)
106
+
107
+ RemLint::Rules::UntilBeforeFrom.new.run(RemLint::Document.new(source))
108
+ end
109
+
110
+ messages = proc { |text| lint.(text).map(&:message) }
111
+
112
+ describe "windows that can never open" do
113
+ it "reports an UNTIL before the FROM" do
114
+ messages.("REM Mon FROM 2027-03-01 UNTIL 2027-01-01 MSG hi\n").first.should ==
115
+ "`UNTIL 2027-01-01` is earlier than `FROM 2027-03-01`, so this reminder can never trigger"
116
+ end
117
+
118
+ it "reports an UNTIL earlier than a SCANFROM" do
119
+ # Remind warns about this one separately, and tests/test.rem marks it
120
+ # "Diagnosed" alongside the FROM form.
121
+ messages.("REM Mon SCANFROM 1992-01-01 UNTIL 1991-12-31 MSG hi\n").first.should.match(
122
+ /earlier than `SCANFROM 1992-01-01`/,
123
+ )
124
+ end
125
+
126
+ it "reports THROUGH, which is the same clause" do
127
+ messages.("REM Mon FROM 2027-03-01 THROUGH 2027-01-01 MSG hi\n").first.should.match(
128
+ /`THROUGH 2027-01-01` is earlier than/,
129
+ )
130
+ end
131
+
132
+ it "reads the spelled-out date form" do
133
+ messages.("REM Mon FROM 1 March 2027 UNTIL 1 Jan 2027 MSG hi\n").length.should == 1
134
+ end
135
+
136
+ it "reads the two forms mixed" do
137
+ messages.("REM Mon FROM 2027-03-01 UNTIL 1 Jan 2027 MSG hi\n").length.should == 1
138
+ end
139
+
140
+ it "points at the expiry clause" do
141
+ text = "REM Mon FROM 2027-03-01 UNTIL 2027-01-01 MSG hi\n"
142
+
143
+ lint.(text).first.column.should == text.index("UNTIL") + 1
144
+ end
145
+ end
146
+
147
+ describe "windows that are fine" do
148
+ it "accepts an UNTIL after the FROM" do
149
+ lint.("REM Mon FROM 2027-01-01 UNTIL 2027-03-01 MSG hi\n").should.be.empty
150
+ end
151
+
152
+ it "accepts an UNTIL one day after the FROM" do
153
+ lint.("REM Mon FROM 2027-01-01 UNTIL 2027-01-02 MSG hi\n").should.be.empty
154
+ end
155
+
156
+ it "accepts a one-day window, which is legal and fires" do
157
+ # `REM MON FROM 1992-01-06 UNTIL 1992-01-06` triggers on 1992-01-06.
158
+ lint.("REM Mon FROM 2027-03-01 UNTIL 2027-03-01 MSG hi\n").should.be.empty
159
+ end
160
+
161
+ it "accepts a reminder with only one of the two" do
162
+ lint.("REM Mon FROM 2027-01-01 MSG hi\n").should.be.empty
163
+ lint.("REM Mon UNTIL 2027-01-01 MSG hi\n").should.be.empty
164
+ end
165
+
166
+ it "accepts a reminder with neither" do
167
+ lint.("REM Mon MSG hi\n").should.be.empty
168
+ end
169
+
170
+ it "says nothing about a SCANFROM written as a delta" do
171
+ # `SCANFROM -7` has no date to compare against.
172
+ lint.("REM Mon SCANFROM -7 UNTIL 2027-01-01 MSG hi\n").should.be.empty
173
+ end
174
+
175
+ it "does not read the two clauses across different commands" do
176
+ text = "REM Mon FROM 2027-03-01 MSG a\nREM Tue UNTIL 2027-01-01 MSG b\n"
177
+
178
+ lint.(text).should.be.empty
179
+ end
180
+ end
181
+
182
+ describe "dates it cannot read" do
183
+ it "says nothing when a date is computed" do
184
+ lint.("REM Mon FROM [x] UNTIL 2027-01-01 MSG hi\n").should.be.empty
185
+ end
186
+
187
+ it "says nothing when a date is partial" do
188
+ # GetFullDate would reject it; that is a different rule's offence.
189
+ lint.("REM Mon FROM 1 Jan UNTIL 2027-01-01 MSG hi\n").should.be.empty
190
+ end
191
+ end
192
+
193
+ it "says nothing about comments" do
194
+ lint.("# FROM 2027-03-01 UNTIL 2027-01-01\n").should.be.empty
195
+ end
196
+
197
+ it "reports at error severity" do
198
+ lint.("REM Mon FROM 2027-03-01 UNTIL 2027-01-01 MSG hi\n").first.severity.should == "error"
199
+ end
200
+
201
+ it "reports the physical line inside a continuation" do
202
+ lint.("REM Mon FROM 2027-03-01 \\\n UNTIL 2027-01-01 MSG hi\n").first.line.should == 2
203
+ end
204
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module RemLint
6
+ module Rules
7
+ # A reminder file anyone can write to.
8
+ #
9
+ # Remind refuses a world-writable script outright, and disables `RUN` for
10
+ # one it does not own -- because a reminder file is a program, and a
11
+ # program anybody can edit will eventually run something somebody else
12
+ # wrote. The check is in src/files.c (`CheckSafety`).
13
+ #
14
+ # This is the only rule that looks at a file rather than at its contents,
15
+ # and the only one that can fail on a file that is textually perfect. It
16
+ # earns its place because the failure mode is a cron job that stops
17
+ # working, or worse does not, and the mode is invisible in every editor.
18
+ #
19
+ # Group-writable is reported separately and more quietly: Remind allows it,
20
+ # but it means the file's safety rests on the group's membership.
21
+ class WorldWritableScript < Rule
22
+ WORLD_WRITABLE = 0o002
23
+ GROUP_WRITABLE = 0o020
24
+
25
+ def self.default_severity
26
+ "error"
27
+ end
28
+
29
+ def self.description
30
+ "A reminder file that is world-writable, which Remind refuses to read."
31
+ end
32
+
33
+ def check
34
+ mode = mode_of(document.path)
35
+
36
+ if mode
37
+ report(mode)
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ # Only a real file on disk has a mode; text linted from a string has
44
+ # nothing to check.
45
+ def mode_of(path)
46
+ if File.file?(path)
47
+ File.stat(path).mode
48
+ end
49
+ rescue SystemCallError
50
+ nil
51
+ end
52
+
53
+ def report(mode)
54
+ if (mode & WORLD_WRITABLE).positive?
55
+ offend(
56
+ document.line_number_at(0),
57
+ "this file is world-writable (mode #{format('%<mode>04o', mode: mode & 0o7777)}); " \
58
+ "Remind refuses to read a script anyone can edit",
59
+ )
60
+ elsif (mode & GROUP_WRITABLE).positive?
61
+ offend(
62
+ document.line_number_at(0),
63
+ "this file is group-writable (mode #{format('%<mode>04o', mode: mode & 0o7777)}); " \
64
+ "Remind allows it, but the script's safety then rests on the group's membership",
65
+ )
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+
72
+ __END__
73
+
74
+ require "fileutils"
75
+ require "tmpdir"
76
+
77
+ require_relative "../document"
78
+
79
+ describe "RemLint::Rules::WorldWritableScript" do
80
+ root = Dir.mktmpdir("remlint-modes")
81
+
82
+ written = proc do |mode|
83
+ path = File.join(root, "mode-#{format('%o', mode)}.rem")
84
+ File.write(path, "MSG hi\n")
85
+ File.chmod(mode, path)
86
+
87
+ RemLint::Rules::WorldWritableScript.new.run(
88
+ RemLint::Document.new(RemLint::Source.new(path: path, text: "MSG hi\n")),
89
+ )
90
+ end
91
+
92
+ it "accepts a file only its owner can write" do
93
+ written.(0o644).should.be.empty
94
+ written.(0o600).should.be.empty
95
+ end
96
+
97
+ it "reports a world-writable file" do
98
+ written.(0o666).first.message.should.match(
99
+ /world-writable \(mode 0666\); Remind refuses to read a script anyone can edit/,
100
+ )
101
+ end
102
+
103
+ it "reports a world-writable file whatever else it allows" do
104
+ written.(0o777).length.should == 1
105
+ end
106
+
107
+ it "reports a group-writable file more quietly" do
108
+ written.(0o664).first.message.should.match(/group-writable \(mode 0664\)/)
109
+ end
110
+
111
+ it "prefers the world-writable message when both apply" do
112
+ written.(0o666).first.message.should.match(/world-writable/)
113
+ end
114
+
115
+ it "says nothing when there is no file on disk to stat" do
116
+ source = RemLint::Source.new(path: "not-a-real-path.rem", text: "MSG hi\n")
117
+
118
+ RemLint::Rules::WorldWritableScript.new.run(RemLint::Document.new(source)).should.be.empty
119
+ end
120
+
121
+ it "reports on the first line of the source" do
122
+ written.(0o666).first.line.should == 1
123
+ end
124
+
125
+ it "reports at error severity" do
126
+ written.(0o666).first.severity.should == "error"
127
+ end
128
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rule"
4
+
5
+ # Requiring a rule file registers the rule, so this list is the only place a
6
+ # new rule has to be mentioned. Sorted by name to make an addition a one-line
7
+ # diff rather than an argument about ordering.
8
+ require_relative "rules/addomit_without_scanfrom"
9
+ require_relative "rules/advance_warning_body"
10
+ require_relative "rules/banner_placement"
11
+ require_relative "rules/calendar_text_limited"
12
+ require_relative "rules/callback_signature"
13
+ require_relative "rules/clause_needs_full_date"
14
+ require_relative "rules/clause_requires_at"
15
+ require_relative "rules/clause_value_range"
16
+ require_relative "rules/color_component_range"
17
+ require_relative "rules/coordinate_not_string"
18
+ require_relative "rules/dangling_continuation"
19
+ require_relative "rules/date_out_of_range"
20
+ require_relative "rules/debug_command"
21
+ require_relative "rules/easterdate_from_today"
22
+ require_relative "rules/function_arity"
23
+ require_relative "rules/function_redefinition"
24
+ require_relative "rules/generated_file_edited"
25
+ require_relative "rules/hebrew_date"
26
+ require_relative "rules/iftrig_with_satisfy"
27
+ require_relative "rules/include_path"
28
+ require_relative "rules/info_clause"
29
+ require_relative "rules/info_substitution_without_header"
30
+ require_relative "rules/invocation_mismatch"
31
+ require_relative "rules/keyword_case"
32
+ require_relative "rules/license_header"
33
+ require_relative "rules/line_length"
34
+ require_relative "rules/literal_type_mismatch"
35
+ require_relative "rules/localization_pack"
36
+ require_relative "rules/moon_phase_argument"
37
+ require_relative "rules/omit_aware_delta"
38
+ require_relative "rules/push_vars_missing_name"
39
+ require_relative "rules/repeat_trigger"
40
+ require_relative "rules/satisfy_constraint"
41
+ require_relative "rules/shell_maxlen"
42
+ require_relative "rules/shell_use_while_run_disabled"
43
+ require_relative "rules/string_escape"
44
+ require_relative "rules/syntax"
45
+ require_relative "rules/system_variable_assignment"
46
+ require_relative "rules/tag_syntax"
47
+ require_relative "rules/text_after_eof_marker"
48
+ require_relative "rules/time_zone_name"
49
+ require_relative "rules/tk_tag_namespace"
50
+ require_relative "rules/todo_complete_through"
51
+ require_relative "rules/trailing_whitespace"
52
+ require_relative "rules/translate_command"
53
+ require_relative "rules/unbalanced_blocks"
54
+ require_relative "rules/unbalanced_delimiters"
55
+ require_relative "rules/unknown_special_type"
56
+ require_relative "rules/unknown_substitution_sequence"
57
+ require_relative "rules/unknown_system_variable"
58
+ require_relative "rules/unquoted_shell_substitution"
59
+ require_relative "rules/until_before_from"
60
+ require_relative "rules/world_writable_script"
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ require_relative "config"
6
+ require_relative "document"
7
+ require_relative "extractors"
8
+
9
+ module RemLint
10
+ # Walks paths, extracts Remind out of them, runs the enabled rules, and drops
11
+ # whatever the file itself asked to be quiet about.
12
+ class Runner
13
+ # `# remlint:disable UnbalancedBlocks, TrailingWhitespace`, or `all`.
14
+ # Recognised after either comment character, and mid-line, so it can sit at
15
+ # the end of the line it applies to.
16
+ DISABLE = /[#;]\s*remlint:disable\s+(?<rules>[\w\s,-]+)/
17
+
18
+ ALL = "all"
19
+
20
+ # Extensions worth opening when walking a directory. A file with no
21
+ # extension is opened too, because `examples/astro` and `examples/ansitext`
22
+ # have none and are where most of the Remind in that directory lives.
23
+ CANDIDATE = /\.(rem|sh|bash)\z|\A[^.]+\z/
24
+
25
+ attr_reader :config, :warnings
26
+
27
+ def initialize(config = Config.default)
28
+ @config = config
29
+ @warnings = []
30
+ end
31
+
32
+ # Returns the offences, worst-placed first in reading order.
33
+ def run(paths)
34
+ files(paths).flat_map { |path| lint_file(path) }.sort_by(&:sort_key)
35
+ end
36
+
37
+ # The files a set of paths expands to: a named file is taken as given, a
38
+ # named directory is walked.
39
+ def files(paths)
40
+ Array(paths).flat_map { |path| expand(path) }.uniq.reject { |path| config.excluded?(path) }
41
+ end
42
+
43
+ def lint_file(path)
44
+ text = read(path)
45
+
46
+ if text.nil?
47
+ []
48
+ else
49
+ lint_text(path, text)
50
+ end
51
+ end
52
+
53
+ def lint_text(path, text)
54
+ rules = config.rules
55
+
56
+ Extractors.extract(path, text).flat_map do |source|
57
+ lint_source(Document.new(source), rules)
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def expand(path)
64
+ pathname = Pathname.new(path)
65
+
66
+ if pathname.directory?
67
+ walk(pathname)
68
+ else
69
+ [path.to_s]
70
+ end
71
+ end
72
+
73
+ def walk(directory)
74
+ directory.find.filter_map do |entry|
75
+ if entry.file? && entry.basename.to_s.match?(CANDIDATE)
76
+ entry.to_s
77
+ end
78
+ end
79
+ end
80
+
81
+ # A file Remind can read is a file of bytes; one that is not valid UTF-8
82
+ # is not Remind's problem or ours, and neither is one we cannot open.
83
+ def read(path)
84
+ text = File.read(path, encoding: "UTF-8")
85
+
86
+ if text.valid_encoding?
87
+ text
88
+ end
89
+ rescue SystemCallError
90
+ nil
91
+ end
92
+
93
+ def lint_source(document, rules)
94
+ found = rules.flat_map { |rule| rule.run(document) }
95
+
96
+ note_unavailable(rules)
97
+
98
+ found.reject { |offense| disabled?(document, offense) }
99
+ end
100
+
101
+ # A rule that could not do its job says so once for the whole run, not
102
+ # once per file and not silently. `Syntax` is the only one: skipping
103
+ # quietly would let a build believe it is syntax-checking when the
104
+ # `remind` it needs is not installed.
105
+ def note_unavailable(rules)
106
+ rules.each do |rule|
107
+ missing = rule.respond_to?(:unavailable) && rule.unavailable
108
+
109
+ if missing
110
+ record(
111
+ "the Syntax rule is enabled but `#{missing}` is not on PATH, " \
112
+ "so no file was syntax-checked",
113
+ )
114
+ end
115
+ end
116
+ end
117
+
118
+ def record(warning)
119
+ unless @warnings.include?(warning)
120
+ @warnings << warning
121
+ end
122
+ end
123
+
124
+ # A directive applies to the line it is on and to the line below it, so
125
+ # it can be written after the offending line or above it -- puppet-lint's
126
+ # two forms, which cover the case where the line has no room left.
127
+ def disabled?(document, offense)
128
+ index = offense.line - document.line_offset - 1
129
+
130
+ [index, index - 1].any? do |candidate|
131
+ directive_covers?(document.raw_lines[candidate], offense.rule, candidate)
132
+ end
133
+ end
134
+
135
+ def directive_covers?(raw, rule_name, index)
136
+ match = index >= 0 && raw&.match(DISABLE)
137
+
138
+ if match
139
+ named = match[:rules].split(/[,\s]+/).reject(&:empty?)
140
+
141
+ named.include?(ALL) || named.include?(rule_name)
142
+ end
143
+ end
144
+ end
145
+ end
146
+
147
+ __END__
148
+
149
+ require "tempfile"
150
+
151
+ require_relative "rules/trailing_whitespace"
152
+ require_relative "rules/unbalanced_blocks"
153
+
154
+ describe "RemLint::Runner" do
155
+ # Just the two rules, so the assertions do not shift when a rule is added.
156
+ config = RemLint::Config.default.only(%w[TrailingWhitespace UnbalancedBlocks])
157
+ runner = RemLint::Runner.new(config)
158
+
159
+ lint = proc { |text, path = "t.rem"| runner.lint_text(path, text) }
160
+ rules_of = proc { |text| lint.(text).map(&:rule) }
161
+
162
+ describe "linting text" do
163
+ it "runs every enabled rule" do
164
+ rules_of.("IF a\nMSG hi \n").sort.should == %w[TrailingWhitespace UnbalancedBlocks]
165
+ end
166
+
167
+ it "returns nothing for a clean file" do
168
+ lint.("IF a\n MSG hi\nENDIF\n").should.be.empty
169
+ end
170
+
171
+ it "runs only the rules the config leaves on" do
172
+ only_blocks = RemLint::Runner.new(RemLint::Config.default.only(%w[UnbalancedBlocks]))
173
+
174
+ only_blocks.lint_text("t.rem", "IF a\nMSG hi \n").map(&:rule).should == ["UnbalancedBlocks"]
175
+ end
176
+
177
+ it "sorts offences into reading order when running over real paths" do
178
+ Tempfile.create(["spec", ".rem"]) do |file|
179
+ file.write("MSG trailing \nIF a\nMSG also trailing \n")
180
+ file.flush
181
+
182
+ runner.run([file.path]).map { |offense| [offense.line, offense.rule] }.should ==
183
+ [[1, "TrailingWhitespace"], [2, "UnbalancedBlocks"], [3, "TrailingWhitespace"]]
184
+ end
185
+ end
186
+ end
187
+
188
+ describe "extraction" do
189
+ it "lints a .rem file whole" do
190
+ lint.("MSG hi \n").length.should == 1
191
+ end
192
+
193
+ it "lints a heredoc and reports positions in the enclosing file" do
194
+ script = "#!/bin/sh\n# note\nremind - <<'EOF'\nMSG hi \nEOF\n"
195
+
196
+ offenses = lint.(script, "ansitext")
197
+ offenses.length.should == 1
198
+ offenses.first.line.should == 4
199
+ end
200
+
201
+ it "ignores a file that holds no Remind at all" do
202
+ lint.("#!/bin/sh\necho hello \n", "build.sh").should.be.empty
203
+ end
204
+ end
205
+
206
+ describe "disable comments" do
207
+ it "drops an offence named on the same line" do
208
+ lint.("MSG hi # remlint:disable TrailingWhitespace\n").should.be.empty
209
+ end
210
+
211
+ it "drops an offence named on the line above" do
212
+ lint.("# remlint:disable TrailingWhitespace\nMSG hi \n").should.be.empty
213
+ end
214
+
215
+ it "accepts the semicolon comment character too" do
216
+ lint.("; remlint:disable TrailingWhitespace\nMSG hi \n").should.be.empty
217
+ end
218
+
219
+ it "drops several rules named together" do
220
+ text = "# remlint:disable TrailingWhitespace, UnbalancedBlocks\nIF a\nMSG hi \n"
221
+
222
+ lint.(text).map(&:rule).should == ["TrailingWhitespace"]
223
+ end
224
+
225
+ it "drops everything when told all" do
226
+ lint.("# remlint:disable all\nMSG hi \n").should.be.empty
227
+ end
228
+
229
+ it "leaves an offence a directive does not name" do
230
+ lint.("# remlint:disable UnbalancedBlocks\nMSG hi \n").map(&:rule).should ==
231
+ ["TrailingWhitespace"]
232
+ end
233
+
234
+ it "does not reach a line further than one below the directive" do
235
+ lint.("# remlint:disable TrailingWhitespace\nMSG ok\nMSG hi \n").length.should == 1
236
+ end
237
+ end
238
+
239
+ describe "run warnings" do
240
+ it "says once when the Syntax rule cannot find its command" do
241
+ only_syntax = RemLint::Config.default
242
+ .only(%w[Syntax])
243
+ .merge("Syntax" => { "Command" => "definitely-not-installed" })
244
+ runner = RemLint::Runner.new(only_syntax)
245
+
246
+ runner.lint_text("a.rem", "MSG hi\n")
247
+ runner.lint_text("b.rem", "MSG hi\n")
248
+
249
+ runner.warnings.length.should == 1
250
+ runner.warnings.first.should.match(/is not on PATH, so no file was syntax-checked/)
251
+ end
252
+
253
+ it "says nothing when no rule is short of anything" do
254
+ RemLint::Runner.new(config).tap { |r| r.lint_text("a.rem", "MSG hi\n") }
255
+ .warnings.should.be.empty
256
+ end
257
+ end
258
+
259
+ describe "walking paths" do
260
+ it "keeps a named file as given" do
261
+ runner.files(["examples/holidays.rem"]).should == ["examples/holidays.rem"]
262
+ end
263
+
264
+ it "drops a path the config excludes" do
265
+ excluding = RemLint::Runner.new(RemLint::Config.new("Exclude" => ["vendor/**/*"]))
266
+
267
+ excluding.files(["vendor/a.rem", "b.rem"]).should == ["b.rem"]
268
+ end
269
+
270
+ it "returns nothing for a file it cannot read" do
271
+ runner.lint_file("/nonexistent/nowhere.rem").should.be.empty
272
+ end
273
+ end
274
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RemLint
4
+ # A run of Remind code carved out of a file, together with where it sat.
5
+ #
6
+ # `line_offset` is the number of physical lines that preceded this run in the
7
+ # file on disk -- zero for a plain `.rem` file, and the line number of the
8
+ # heredoc's opener for Remind embedded in a shell script. Every line number
9
+ # the linter reports is computed through this offset, so an offence inside
10
+ # the third heredoc of a shell script points at the real line of that script
11
+ # rather than at line 4 of an anonymous fragment.
12
+ Source = Struct.new(
13
+ :path,
14
+ :text,
15
+ :line_offset,
16
+ :description,
17
+ keyword_init: true,
18
+ ) do
19
+ def initialize(path:, text:, line_offset: 0, description: nil)
20
+ super
21
+ end
22
+
23
+ def lines
24
+ text.lines
25
+ end
26
+
27
+ # How to name this source in a message when a file yields more than one.
28
+ def label
29
+ if description
30
+ "#{path} (#{description})"
31
+ else
32
+ path
33
+ end
34
+ end
35
+ end
36
+ end