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,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module RemLint
6
+ module Rules
7
+ # A function that shells out, defined while `RUN` is off.
8
+ #
9
+ # Remind binds the `RUN` permission when a function is *defined*, not when
10
+ # it is called. `userfns.c` stores the flag on the function
11
+ # (`if (RunDisabled) func->run_disabled = 1`) and `expr.c` puts it back on
12
+ # every call (`if (f->run_disabled) RunDisabled |= RUN_UF`). So:
13
+ #
14
+ # RUN OFF
15
+ # FSET listing() shell("ls")
16
+ # RUN ON
17
+ # MSG [listing()] # RUN disabled
18
+ #
19
+ # The function is defined in the sandbox and stays in it for good. Nothing
20
+ # about the definition looks wrong, the `RUN ON` above the call looks like
21
+ # it should be enough, and the error arrives from the call site -- which is
22
+ # the one place the mistake is not.
23
+ #
24
+ # `shell()` is the only builtin that needs the permission (`funcs.c:2545`);
25
+ # `INCLUDECMD` needs it too but is a command, not something a function can
26
+ # contain.
27
+ class ShellUseWhileRunDisabled < Rule
28
+ SHELL = "shell"
29
+
30
+ ON = "ON"
31
+ OFF = "OFF"
32
+
33
+ def self.default_severity
34
+ "error"
35
+ end
36
+
37
+ def self.description
38
+ "A function calling shell(), defined between RUN OFF and RUN ON."
39
+ end
40
+
41
+ def check
42
+ disabled = false
43
+
44
+ document.code_commands.each do |command|
45
+ directive = run_directive(command)
46
+
47
+ if directive
48
+ disabled = directive == OFF
49
+ elsif disabled
50
+ check_definition(command)
51
+ end
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ # `RUN ON` / `RUN OFF` is a directive; any other `RUN` is a reminder
58
+ # that shells out. `DoRun` accepts exactly these two words, in any case.
59
+ def run_directive(command)
60
+ word = command.args.strip.upcase
61
+
62
+ if command.keyword?("RUN") && (word == ON || word == OFF)
63
+ word
64
+ end
65
+ end
66
+
67
+ def check_definition(command)
68
+ if command.keyword?("FSET")
69
+ shell_calls(command).each do |token|
70
+ offend_at(command.logical_line, token.offset, message)
71
+ end
72
+ end
73
+ end
74
+
75
+ def shell_calls(command)
76
+ document.tokens_for(command.logical_line).select do |token|
77
+ token.type == :function && token.value.casecmp?(SHELL)
78
+ end
79
+ end
80
+
81
+ def message
82
+ "`shell()` in a function defined while `RUN` is off: the permission " \
83
+ "is bound here, not at the call, so this fails even after `RUN ON`"
84
+ end
85
+ end
86
+ end
87
+ end
88
+
89
+ __END__
90
+
91
+ require_relative "../document"
92
+
93
+ describe "RemLint::Rules::ShellUseWhileRunDisabled" do
94
+ lint = proc do |text|
95
+ source = RemLint::Source.new(path: "t.rem", text: text)
96
+
97
+ RemLint::Rules::ShellUseWhileRunDisabled.new.run(RemLint::Document.new(source))
98
+ end
99
+
100
+ it "reports a shell() function defined after RUN OFF" do
101
+ offenses = lint.(%(RUN OFF\nFSET listing() shell("ls")\nRUN ON\n))
102
+
103
+ offenses.length.should == 1
104
+ offenses.first.line.should == 2
105
+ offenses.first.message.should.match(/bound here, not at the call/)
106
+ end
107
+
108
+ it "accepts the same definition before RUN OFF" do
109
+ lint.(%(FSET listing() shell("ls")\nRUN OFF\n)).should.be.empty
110
+ end
111
+
112
+ it "accepts the same definition after RUN is turned back on" do
113
+ lint.(%(RUN OFF\nRUN ON\nFSET listing() shell("ls")\n)).should.be.empty
114
+ end
115
+
116
+ it "accepts a file that never turns RUN off" do
117
+ lint.(%(FSET listing() shell("ls")\n)).should.be.empty
118
+ end
119
+
120
+ it "reports every offending definition in the disabled span" do
121
+ text = %(RUN OFF\nFSET a() shell("ls")\nFSET b() shell("date")\nRUN ON\n)
122
+
123
+ lint.(text).map(&:line).should == [2, 3]
124
+ end
125
+
126
+ it "reports two calls in one definition" do
127
+ lint.(%(RUN OFF\nFSET a() shell("ls") + shell("date")\n)).length.should == 2
128
+ end
129
+
130
+ it "matches RUN OFF whatever its case" do
131
+ lint.(%(run off\nFSET a() shell("ls")\n)).length.should == 1
132
+ end
133
+
134
+ it "matches shell whatever its case" do
135
+ lint.(%(RUN OFF\nFSET a() SHELL("ls")\n)).length.should == 1
136
+ end
137
+
138
+ it "points at the call" do
139
+ text = %(RUN OFF\nFSET listing() shell("ls")\n)
140
+
141
+ lint.(text).first.column.should == %(FSET listing() shell("ls")).index("shell") + 1
142
+ end
143
+
144
+ describe "what it leaves alone" do
145
+ it "says nothing about a function that does not shell out" do
146
+ lint.("RUN OFF\nFSET double(x) x * 2\n").should.be.empty
147
+ end
148
+
149
+ it "says nothing about a RUN reminder, which is not a directive" do
150
+ # `RUN OFF` is the directive; `RUN echo off` is a reminder that runs a
151
+ # command, and it does not disable anything.
152
+ lint.(%(REM 1 Jan RUN echo off\nFSET a() shell("ls")\n)).should.be.empty
153
+ end
154
+
155
+ it "does not read a bare RUN reminder as a directive" do
156
+ lint.(%(RUN echo hello\nFSET a() shell("ls")\n)).should.be.empty
157
+ end
158
+
159
+ it "says nothing about a shell() call outside a definition" do
160
+ # That one fails visibly, at the point it is written.
161
+ lint.(%(RUN OFF\nSET x shell("ls")\n)).should.be.empty
162
+ end
163
+
164
+ it "says nothing about comments" do
165
+ lint.(%(RUN OFF\n# FSET a() shell("ls")\n)).should.be.empty
166
+ end
167
+ end
168
+
169
+ it "reports at error severity" do
170
+ lint.(%(RUN OFF\nFSET a() shell("ls")\n)).first.severity.should == "error"
171
+ end
172
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module RemLint
6
+ module Rules
7
+ # Backslash escapes in a string that are not escapes.
8
+ #
9
+ # Remind's escape set is `\a \b \f \n \r \t \v` and `\xHH`. Anything else
10
+ # reaches a `default:` branch in src/expr.c that emits the character and
11
+ # drops the backslash -- so `\q` is `q`, silently, with no way to tell it
12
+ # from a `q` somebody meant to type.
13
+ #
14
+ # `\\` and `\"` go through that same default branch, which is exactly how
15
+ # they work, so neither is reported: writing them is the fix, not the
16
+ # fault.
17
+ #
18
+ # `\x00` is different, and is the one case Remind refuses outright: a NUL
19
+ # cannot appear inside a Remind string, and the parser says so.
20
+ class StringEscape < Rule
21
+ # From the `switch(**in)` over escapes in src/expr.c.
22
+ DEFINED = %w[a b f n r t v x].freeze
23
+
24
+ # Handled by the default branch, and correct.
25
+ LITERAL = ['\\', '"', "'"].freeze
26
+
27
+ ESCAPE = /\\(?<body>x[0-9a-fA-F]{1,2}|.)/
28
+
29
+ NUL = /\Ax0+\z/
30
+
31
+ def self.default_severity
32
+ "warning"
33
+ end
34
+
35
+ def self.description
36
+ "A backslash escape Remind does not define, or the prohibited \\x00."
37
+ end
38
+
39
+ def check
40
+ document.logical_lines.each_with_index do |logical_line, index|
41
+ if document.commands[index].code?
42
+ check_line(logical_line)
43
+ end
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def check_line(logical_line)
50
+ document.tokens_for(logical_line).each do |token|
51
+ if token.type == :string && token.value.start_with?('"')
52
+ check_string(logical_line, token)
53
+ end
54
+ end
55
+ end
56
+
57
+ def check_string(logical_line, token)
58
+ token.value.to_enum(:scan, ESCAPE).each do
59
+ match = Regexp.last_match
60
+ complaint = fault(match[:body])
61
+
62
+ if complaint
63
+ offend_at(logical_line, token.offset + match.begin(0), complaint)
64
+ end
65
+ end
66
+ end
67
+
68
+ def fault(body)
69
+ if body.match?(NUL)
70
+ "`\\#{body}` is prohibited outright -- a NUL cannot appear inside a Remind string"
71
+ elsif !DEFINED.include?(body[0]) && !LITERAL.include?(body[0])
72
+ "`\\#{body}` is not an escape Remind defines; it drops the backslash " \
73
+ "and prints `#{body}`"
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+
80
+ __END__
81
+
82
+ require_relative "../document"
83
+
84
+ describe "RemLint::Rules::StringEscape" do
85
+ lint = proc do |text|
86
+ source = RemLint::Source.new(path: "t.rem", text: text)
87
+
88
+ RemLint::Rules::StringEscape.new.run(RemLint::Document.new(source))
89
+ end
90
+
91
+ messages = proc { |text| lint.(text).map(&:message) }
92
+
93
+ describe "escapes Remind defines" do
94
+ it "accepts the control escapes" do
95
+ lint.(%(SET a "one\\ttwo\\nthree"\n)).should.be.empty
96
+ lint.(%(SET a "\\a\\b\\f\\r\\v"\n)).should.be.empty
97
+ end
98
+
99
+ it "accepts a hex escape" do
100
+ lint.(%(SET a "\\x41\\x7f"\n)).should.be.empty
101
+ end
102
+
103
+ it "accepts an escaped backslash and quote" do
104
+ # Both go through the same default branch, which is how they work.
105
+ lint.(%(SET a "a \\\\ b \\" c"\n)).should.be.empty
106
+ end
107
+ end
108
+
109
+ describe "escapes Remind does not define" do
110
+ it "reports an unknown letter" do
111
+ messages.(%(SET a "\\q"\n)).first.should ==
112
+ "`\\q` is not an escape Remind defines; it drops the backslash and prints `q`"
113
+ end
114
+
115
+ it "reports each one" do
116
+ messages.(%(SET a "\\q and \\z"\n)).length.should == 2
117
+ end
118
+
119
+ it "points at the backslash" do
120
+ text = %(SET a "x\\qy"\n)
121
+
122
+ lint.(text).first.column.should == text.index("\\q") + 1
123
+ end
124
+ end
125
+
126
+ describe "the prohibited NUL" do
127
+ it "reports \\x00" do
128
+ messages.(%(SET a "\\x00"\n)).first.should.match(/prohibited outright/)
129
+ end
130
+
131
+ it "reports \\x0 as well" do
132
+ messages.(%(SET a "\\x0"\n)).first.should.match(/prohibited outright/)
133
+ end
134
+
135
+ it "accepts a hex escape that is not zero" do
136
+ lint.(%(SET a "\\x01"\n)).should.be.empty
137
+ end
138
+ end
139
+
140
+ describe "where it does not look" do
141
+ it "says nothing about a single-quoted date constant" do
142
+ lint.("SET a '2027-01-01'\n").should.be.empty
143
+ end
144
+
145
+ it "says nothing about a backslash outside a string" do
146
+ # A trailing backslash is a line continuation, not an escape.
147
+ lint.("SET a 1 + \\\n 2\n").should.be.empty
148
+ end
149
+
150
+ it "says nothing about comments" do
151
+ lint.(%(# SET a "\\q"\n)).should.be.empty
152
+ end
153
+ end
154
+
155
+ it "reports at warning severity" do
156
+ lint.(%(SET a "\\q"\n)).first.severity.should == "warning"
157
+ end
158
+ end
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "tempfile"
5
+
6
+ require_relative "../rule"
7
+
8
+ module RemLint
9
+ module Rules
10
+ # Remind's own diagnostics, folded into the linter's output.
11
+ #
12
+ # puppet-lint draws the line here and it is the right line: a linter
13
+ # validates style, and for "is this even valid" you run the real parser.
14
+ # Remind has no parse-only mode, so the real parser is Remind, and running
15
+ # it means running the file.
16
+ #
17
+ # THIS RULE IS OFF BY DEFAULT, and stays off unless someone turns it on,
18
+ # because of what running the file means:
19
+ #
20
+ # INCLUDE and INCLUDECMD read -- and INCLUDECMD executes -- other things
21
+ # RUN reminders shell out
22
+ #
23
+ # `-r` is passed to disable RUN directives, `-q` to keep timed reminders
24
+ # out of the queue, and `-n` to ask only for next occurrences. That covers
25
+ # the RUN half. It does not cover INCLUDECMD, so turn this on for files you
26
+ # trust and leave it off for files you do not.
27
+ #
28
+ # When `remind` is not on PATH the rule reports nothing rather than
29
+ # failing: a linter that cannot run on a machine without Remind installed
30
+ # is a linter that cannot run in CI. It does say so once, through
31
+ # `Runner`, because silently not syntax-checking is worse than not
32
+ # syntax-checking -- a build can otherwise believe it is covered when it
33
+ # is not.
34
+ class Syntax < Rule
35
+ DEFAULT_COMMAND = "remind"
36
+
37
+ # `file(12): Some message` and `file(12:14): Some message`, the two forms
38
+ # src/main.c prints depending on whether the command spanned lines.
39
+ DIAGNOSTIC = /\A(?<file>[^(]+)\((?<line>\d+)(?::(?<end_line>\d+))?\):\s*(?<message>.*)\z/
40
+
41
+ # Remind labels its own non-fatal diagnostics -- "Warning: Missing ENDIF"
42
+ # and friends -- so they are relayed as warnings rather than promoted to
43
+ # errors by this rule's configured severity.
44
+ WARNING = /\Awarning\b/i
45
+
46
+ def self.enabled_by_default?
47
+ false
48
+ end
49
+
50
+ def self.default_severity
51
+ "error"
52
+ end
53
+
54
+ def self.description
55
+ "Diagnostics from running the file through Remind itself (off by default)."
56
+ end
57
+
58
+ # Set once per run when the configured command is missing, so `Runner`
59
+ # can say so a single time rather than per file.
60
+ attr_reader :unavailable
61
+
62
+ def check
63
+ command = option("Command", DEFAULT_COMMAND)
64
+ @unavailable = nil
65
+
66
+ if executable?(command)
67
+ run_remind(command)
68
+ else
69
+ @unavailable = command
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def executable?(command)
76
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |directory|
77
+ File.executable?(File.join(directory, command))
78
+ end
79
+ end
80
+
81
+ # The source is written out rather than piped, because a heredoc's
82
+ # Remind is only part of its file and Remind must not see the shell
83
+ # around it. Line numbers come back relative to the temporary file and
84
+ # are shifted onto the real one.
85
+ def run_remind(command)
86
+ Tempfile.create(["remlint", ".rem"]) do |file|
87
+ file.write(document.source.text)
88
+ file.flush
89
+
90
+ report(capture(command, file.path))
91
+ end
92
+ end
93
+
94
+ def capture(command, path)
95
+ output, _status = Open3.capture2e(
96
+ command,
97
+ "-n",
98
+ "-r",
99
+ "-q",
100
+ path,
101
+ )
102
+
103
+ output
104
+ rescue SystemCallError => error
105
+ "#{path}(1): could not run #{command}: #{error.message}"
106
+ end
107
+
108
+ def report(output)
109
+ output.each_line do |raw|
110
+ match = raw.chomp.match(DIAGNOSTIC)
111
+
112
+ if match
113
+ relay(match)
114
+ end
115
+ end
116
+ end
117
+
118
+ def relay(match)
119
+ message = match[:message].strip
120
+
121
+ offend(shifted(match[:line]), message, severity: graded(message))
122
+ end
123
+
124
+ # nil means "use whatever the configuration says", which is what an
125
+ # unlabelled diagnostic gets.
126
+ def graded(message)
127
+ if message.match?(WARNING)
128
+ "warning"
129
+ end
130
+ end
131
+
132
+ def shifted(line)
133
+ Integer(line) + document.line_offset
134
+ end
135
+ end
136
+ end
137
+ end
138
+
139
+ __END__
140
+
141
+ require_relative "../document"
142
+
143
+ describe "RemLint::Rules::Syntax" do
144
+ lint = proc do |text, config = {}|
145
+ source = RemLint::Source.new(path: "t.rem", text: text)
146
+
147
+ RemLint::Rules::Syntax.new(config).run(RemLint::Document.new(source))
148
+ end
149
+
150
+ it "is off unless the configuration asks for it" do
151
+ RemLint::Rules::Syntax.enabled_by_default?.should.be.false
152
+ end
153
+
154
+ it "reports nothing when the configured command is not on PATH" do
155
+ lint.("this is not valid remind at all\n", "Command" => "definitely-not-installed").should.be.empty
156
+ end
157
+
158
+ it "records which command was missing, so the runner can say so once" do
159
+ rule = RemLint::Rules::Syntax.new("Command" => "definitely-not-installed")
160
+ source = RemLint::Source.new(path: "t.rem", text: "MSG hi\n")
161
+
162
+ rule.run(RemLint::Document.new(source))
163
+ rule.unavailable.should == "definitely-not-installed"
164
+ end
165
+
166
+ describe "parsing Remind's diagnostics" do
167
+ # A stub that prints what Remind prints, so the parsing is tested without
168
+ # requiring Remind to be installed wherever the suite runs.
169
+ stub = proc do |output|
170
+ directory = Dir.mktmpdir("remlint-spec")
171
+ path = File.join(directory, "fake-remind")
172
+
173
+ File.write(path, "#!/bin/sh\ncat <<'DIAGNOSTICS'\n#{output}\nDIAGNOSTICS\n")
174
+ File.chmod(0o755, path)
175
+ ENV["PATH"] = "#{directory}#{File::PATH_SEPARATOR}#{ENV.fetch('PATH')}"
176
+
177
+ "fake-remind"
178
+ end
179
+
180
+ it "turns a single-line diagnostic into an offence" do
181
+ command = stub.("/tmp/x.rem(3): Missing ']'")
182
+ offenses = lint.("MSG [x\n", "Command" => command)
183
+
184
+ offenses.length.should == 1
185
+ offenses.first.line.should == 3
186
+ offenses.first.message.should == "Missing ']'"
187
+ end
188
+
189
+ it "takes the first line of a diagnostic that names a line range" do
190
+ command = stub.("/tmp/x.rem(3:5): Missing ')'")
191
+
192
+ lint.("MSG hi\n", "Command" => command).first.line.should == 3
193
+ end
194
+
195
+ it "reports every diagnostic Remind prints" do
196
+ command = stub.("/tmp/x.rem(1): First\n/tmp/x.rem(2): Second")
197
+
198
+ lint.("MSG hi\n", "Command" => command).map(&:line).should == [1, 2]
199
+ end
200
+
201
+ it "ignores output that is not a diagnostic" do
202
+ command = stub.("MSG hi\nsomething else entirely")
203
+
204
+ lint.("MSG hi\n", "Command" => command).should.be.empty
205
+ end
206
+
207
+ it "shifts a heredoc's diagnostics onto the enclosing file" do
208
+ command = stub.("/tmp/x.rem(2): Missing ']'")
209
+ source = RemLint::Source.new(path: "astro", text: "MSG hi\nMSG [x\n", line_offset: 12)
210
+ rule = RemLint::Rules::Syntax.new("Command" => command)
211
+
212
+ rule.run(RemLint::Document.new(source)).first.line.should == 14
213
+ end
214
+
215
+ it "reports at error severity" do
216
+ command = stub.("/tmp/x.rem(1): Missing ']'")
217
+
218
+ lint.("MSG [x\n", "Command" => command).first.severity.should == "error"
219
+ end
220
+
221
+ it "relays a diagnostic Remind labelled a warning as a warning" do
222
+ command = stub.("/tmp/x.rem(1): Warning: Missing ENDIF")
223
+ offense = lint.("IF a\n", "Command" => command).first
224
+
225
+ offense.severity.should == "warning"
226
+ offense.message.should == "Warning: Missing ENDIF"
227
+ end
228
+ end
229
+ end