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.
- checksums.yaml +7 -0
- data/LICENSE +328 -0
- data/config/default.yml +374 -0
- data/docs/remlint-rules.md +390 -0
- data/docs/remlint.md +238 -0
- data/exe/remlint +8 -0
- data/lib/remlint/cli.rb +300 -0
- data/lib/remlint/command.rb +229 -0
- data/lib/remlint/config.rb +255 -0
- data/lib/remlint/date_literal.rb +283 -0
- data/lib/remlint/document.rb +161 -0
- data/lib/remlint/expr_lexer.rb +197 -0
- data/lib/remlint/extractors.rb +210 -0
- data/lib/remlint/formatter.rb +96 -0
- data/lib/remlint/invocation.rb +145 -0
- data/lib/remlint/logical_line.rb +194 -0
- data/lib/remlint/offense.rb +117 -0
- data/lib/remlint/rule.rb +216 -0
- data/lib/remlint/rules/addomit_without_scanfrom.rb +189 -0
- data/lib/remlint/rules/advance_warning_body.rb +176 -0
- data/lib/remlint/rules/banner_placement.rb +126 -0
- data/lib/remlint/rules/calendar_text_limited.rb +125 -0
- data/lib/remlint/rules/callback_signature.rb +221 -0
- data/lib/remlint/rules/clause_needs_full_date.rb +176 -0
- data/lib/remlint/rules/clause_requires_at.rb +178 -0
- data/lib/remlint/rules/clause_value_range.rb +257 -0
- data/lib/remlint/rules/color_component_range.rb +295 -0
- data/lib/remlint/rules/coordinate_not_string.rb +175 -0
- data/lib/remlint/rules/dangling_continuation.rb +134 -0
- data/lib/remlint/rules/date_out_of_range.rb +180 -0
- data/lib/remlint/rules/debug_command.rb +189 -0
- data/lib/remlint/rules/easterdate_from_today.rb +160 -0
- data/lib/remlint/rules/function_arity.rb +385 -0
- data/lib/remlint/rules/function_redefinition.rb +159 -0
- data/lib/remlint/rules/generated_file_edited.rb +125 -0
- data/lib/remlint/rules/hebrew_date.rb +245 -0
- data/lib/remlint/rules/iftrig_with_satisfy.rb +101 -0
- data/lib/remlint/rules/include_path.rb +155 -0
- data/lib/remlint/rules/info_clause.rb +186 -0
- data/lib/remlint/rules/info_substitution_without_header.rb +169 -0
- data/lib/remlint/rules/invocation_mismatch.rb +223 -0
- data/lib/remlint/rules/keyword_case.rb +172 -0
- data/lib/remlint/rules/license_header.rb +101 -0
- data/lib/remlint/rules/line_length.rb +101 -0
- data/lib/remlint/rules/literal_type_mismatch.rb +324 -0
- data/lib/remlint/rules/localization_pack.rb +144 -0
- data/lib/remlint/rules/moon_phase_argument.rb +162 -0
- data/lib/remlint/rules/omit_aware_delta.rb +168 -0
- data/lib/remlint/rules/push_vars_missing_name.rb +158 -0
- data/lib/remlint/rules/repeat_trigger.rb +188 -0
- data/lib/remlint/rules/satisfy_constraint.rb +230 -0
- data/lib/remlint/rules/shell_maxlen.rb +170 -0
- data/lib/remlint/rules/shell_use_while_run_disabled.rb +172 -0
- data/lib/remlint/rules/string_escape.rb +158 -0
- data/lib/remlint/rules/syntax.rb +229 -0
- data/lib/remlint/rules/system_variable_assignment.rb +197 -0
- data/lib/remlint/rules/tag_syntax.rb +145 -0
- data/lib/remlint/rules/text_after_eof_marker.rb +134 -0
- data/lib/remlint/rules/time_zone_name.rb +212 -0
- data/lib/remlint/rules/tk_tag_namespace.rb +121 -0
- data/lib/remlint/rules/todo_complete_through.rb +122 -0
- data/lib/remlint/rules/trailing_whitespace.rb +116 -0
- data/lib/remlint/rules/translate_command.rb +203 -0
- data/lib/remlint/rules/unbalanced_blocks.rb +305 -0
- data/lib/remlint/rules/unbalanced_delimiters.rb +251 -0
- data/lib/remlint/rules/unknown_special_type.rb +155 -0
- data/lib/remlint/rules/unknown_substitution_sequence.rb +255 -0
- data/lib/remlint/rules/unknown_system_variable.rb +145 -0
- data/lib/remlint/rules/unquoted_shell_substitution.rb +266 -0
- data/lib/remlint/rules/until_before_from.rb +204 -0
- data/lib/remlint/rules/world_writable_script.rb +128 -0
- data/lib/remlint/rules.rb +60 -0
- data/lib/remlint/runner.rb +274 -0
- data/lib/remlint/source.rb +36 -0
- data/lib/remlint/tables.rb +395 -0
- data/lib/remlint/trigger.rb +359 -0
- data/lib/remlint/version.rb +5 -0
- data/lib/remlint/vocabulary.rb +236 -0
- data/lib/remlint.rb +35 -0
- data/tasks/generate_tables.rb +175 -0
- metadata +170 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "strscan"
|
|
4
|
+
|
|
5
|
+
module RemLint
|
|
6
|
+
# One lexeme of a command's body, with where it sat.
|
|
7
|
+
#
|
|
8
|
+
# `offset` is a character offset into the logical line's text, which
|
|
9
|
+
# {LogicalLine#position_at} turns into a file line and column. Tokens carry
|
|
10
|
+
# the offset rather than a line number because a continued command's tokens
|
|
11
|
+
# are spread over several lines and only the offset survives the join.
|
|
12
|
+
Token = Struct.new(
|
|
13
|
+
:type,
|
|
14
|
+
:value,
|
|
15
|
+
:offset,
|
|
16
|
+
keyword_init: true,
|
|
17
|
+
) do
|
|
18
|
+
def length
|
|
19
|
+
value.length
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def end_offset
|
|
23
|
+
offset + length
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Tokenises the inside of a Remind command.
|
|
28
|
+
#
|
|
29
|
+
# Not a parser. Remind's expression grammar is real, but a linter needs far
|
|
30
|
+
# less than a grammar and gets it much more cheaply: what it needs to know is
|
|
31
|
+
# where the string literals are (so it does not count a `[` inside one), where
|
|
32
|
+
# the bracketed expressions are, and where the function calls are. Everything
|
|
33
|
+
# else can stay an undifferentiated run of characters.
|
|
34
|
+
#
|
|
35
|
+
# Strings matter most. `MSG see [ansicolor("")]` has balanced brackets;
|
|
36
|
+
# `MSG a "]" b` does not contain a closing bracket at all. Scanning
|
|
37
|
+
# characters without tracking quotes gets both wrong.
|
|
38
|
+
class ExprLexer
|
|
39
|
+
# Remind takes both quote characters, with backslash escaping inside.
|
|
40
|
+
DOUBLE_QUOTED = /"(?:[^"\\]|\\.)*"?/
|
|
41
|
+
SINGLE_QUOTED = /'(?:[^'\\]|\\.)*'?/
|
|
42
|
+
|
|
43
|
+
# `%a`, `%_`, `%"` -- the substitution sequences, one character after the
|
|
44
|
+
# percent. Matched before anything else can claim the character, which is
|
|
45
|
+
# what remind.vim's `remindSubst` pattern does.
|
|
46
|
+
SUBSTITUTION = /%[^\s]/
|
|
47
|
+
|
|
48
|
+
# `$SysInclude`, `$Latitude`.
|
|
49
|
+
SYSVAR = /\$[A-Za-z_]\w*/
|
|
50
|
+
|
|
51
|
+
# A name immediately followed by `(` is a call; a name not followed by one
|
|
52
|
+
# is a variable read, and the two need different rules applied to them.
|
|
53
|
+
FUNCTION = /[A-Za-z_]\w*(?=\()/
|
|
54
|
+
NAME = /[A-Za-z_]\w*/
|
|
55
|
+
|
|
56
|
+
NUMBER = /\d+(?:\.\d+)?/
|
|
57
|
+
|
|
58
|
+
WHITESPACE = /\s+/
|
|
59
|
+
|
|
60
|
+
def initialize(text)
|
|
61
|
+
@scanner = StringScanner.new(text)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.tokenise(text)
|
|
65
|
+
new(text).tokenise
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def tokenise
|
|
69
|
+
[].tap do |tokens|
|
|
70
|
+
until @scanner.eos?
|
|
71
|
+
tokens << next_token
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Just the tokens that carry meaning -- everything but whitespace.
|
|
77
|
+
def self.significant(text)
|
|
78
|
+
tokenise(text).reject { |token| token.type == :whitespace }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# Ordered by specificity, not by frequency: a `%` that opens a
|
|
84
|
+
# substitution must not be read as an operator, and a name before `(`
|
|
85
|
+
# must not be read as a bare name.
|
|
86
|
+
def next_token
|
|
87
|
+
emit(:string, DOUBLE_QUOTED) ||
|
|
88
|
+
emit(:string, SINGLE_QUOTED) ||
|
|
89
|
+
emit(:whitespace, WHITESPACE) ||
|
|
90
|
+
emit(:lbracket, /\[/) ||
|
|
91
|
+
emit(:rbracket, /\]/) ||
|
|
92
|
+
emit(:lparen, /\(/) ||
|
|
93
|
+
emit(:rparen, /\)/) ||
|
|
94
|
+
emit(:comma, /,/) ||
|
|
95
|
+
emit(:substitution, SUBSTITUTION) ||
|
|
96
|
+
emit(:sysvar, SYSVAR) ||
|
|
97
|
+
emit(:function, FUNCTION) ||
|
|
98
|
+
emit(:name, NAME) ||
|
|
99
|
+
emit(:number, NUMBER) ||
|
|
100
|
+
single_character
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def emit(type, pattern)
|
|
104
|
+
offset = @scanner.pos
|
|
105
|
+
value = @scanner.scan(pattern)
|
|
106
|
+
|
|
107
|
+
if value
|
|
108
|
+
Token.new(type: type, value: value, offset: offset)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Operators, punctuation and anything else: one character, unclassified.
|
|
113
|
+
# A rule that cares can look at the value.
|
|
114
|
+
def single_character
|
|
115
|
+
offset = @scanner.pos
|
|
116
|
+
|
|
117
|
+
Token.new(type: :other, value: @scanner.getch, offset: offset)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
__END__
|
|
123
|
+
|
|
124
|
+
describe "RemLint::ExprLexer" do
|
|
125
|
+
lex = proc { |text| RemLint::ExprLexer.significant(text) }
|
|
126
|
+
types = proc { |text| lex.(text).map(&:type) }
|
|
127
|
+
values = proc { |text| lex.(text).map(&:value) }
|
|
128
|
+
|
|
129
|
+
it "keeps whitespace in the full stream and drops it from the significant one" do
|
|
130
|
+
RemLint::ExprLexer.tokenise("a b").map(&:type).should == %i[name whitespace name]
|
|
131
|
+
types.("a b").should == %i[name name]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it "records the offset of every token" do
|
|
135
|
+
tokens = lex.("MSG [x]")
|
|
136
|
+
|
|
137
|
+
tokens.map(&:offset).should == [0, 4, 5, 6]
|
|
138
|
+
tokens.last.end_offset.should == 7
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
describe "strings" do
|
|
142
|
+
it "takes a double-quoted string whole" do
|
|
143
|
+
values.(%(a "hello world" b)).should == ["a", %("hello world"), "b"]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
it "takes a single-quoted string whole, as Remind's date literals need" do
|
|
147
|
+
values.("x '2026-01-01' y").should == ["x", "'2026-01-01'", "y"]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it "honours backslash escapes inside a string" do
|
|
151
|
+
values.(%("a \\" b" c)).should == [%("a \\" b"), "c"]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it "does not let a bracket inside a string count as a bracket" do
|
|
155
|
+
types.(%(MSG "]" done)).should == %i[name string name]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it "runs an unterminated string to end of line rather than looping" do
|
|
159
|
+
values.(%(MSG "oops)).should == ["MSG", %("oops)]
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
describe "Remind's sigils" do
|
|
164
|
+
it "recognises system variables" do
|
|
165
|
+
types.("INCLUDE [$SysInclude]/ansitext.rem").should ==
|
|
166
|
+
%i[name lbracket sysvar rbracket other name other name]
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
it "recognises a substitution as the percent plus one character" do
|
|
170
|
+
values.("MSG at %2 on %_").should == ["MSG", "at", "%2", "on", "%_"]
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
it "does not read a percent before a space as a substitution" do
|
|
174
|
+
types.("BANNER % ").should == %i[name other]
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
describe "calls" do
|
|
179
|
+
it "distinguishes a call from a bare name" do
|
|
180
|
+
types.("trigger(x)").should == %i[function lparen name rparen]
|
|
181
|
+
types.("trigger").should == %i[name]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
it "lexes a nested call" do
|
|
185
|
+
values.("[ansicolor(0,255,0) + center(x)]").should ==
|
|
186
|
+
["[", "ansicolor", "(", "0", ",", "255", ",", "0", ")", "+", "center", "(", "x", ")", "]"]
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
it "gives every other character its own token rather than dropping it" do
|
|
191
|
+
types.("a >= b").should == %i[name other other name]
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
it "lexes across the newlines a joined command carries" do
|
|
195
|
+
types.("IF a && \n b").should == %i[name name other other name]
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "source"
|
|
4
|
+
|
|
5
|
+
module RemLint
|
|
6
|
+
# Turns a file on disk into zero or more {Source} runs of Remind code.
|
|
7
|
+
#
|
|
8
|
+
# A linter that only globs `*.rem` misses most real Remind code: the shipped
|
|
9
|
+
# `examples/ansitext` and `examples/astro` are shell scripts that pipe Remind
|
|
10
|
+
# in through heredocs, and `astro` alone holds four of them. Linting such a
|
|
11
|
+
# file as if it were Remind produces nothing but noise about the shell.
|
|
12
|
+
#
|
|
13
|
+
# So extraction is its own layer, mirroring how RuboCop pulls Ruby out of ERB
|
|
14
|
+
# and Haml: each extractor either recognises the file and returns its Remind
|
|
15
|
+
# runs, or returns nil and lets the next one look.
|
|
16
|
+
module Extractors
|
|
17
|
+
# `remind ... <<'EOF'` / `<<-EOF` / `<< "EOF"`, with the flags and
|
|
18
|
+
# redirections in between ignored. The quoting around the delimiter is what
|
|
19
|
+
# stops the shell interpolating `$Latitude` before Remind sees it; both
|
|
20
|
+
# quoted and bare forms open a heredoc, so both are matched.
|
|
21
|
+
HEREDOC_OPENER = /\bremind\b[^\n<]*<<(?<dash>-?)\s*(?<quote>['"]?)(?<delimiter>\w+)\k<quote>/
|
|
22
|
+
|
|
23
|
+
# A file Remind is meant to read directly, either by extension or because
|
|
24
|
+
# it runs itself through Remind.
|
|
25
|
+
REM_SHEBANG = /\A#![^\n]*\bremind\b/
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# A plain Remind file: the whole text, no offset.
|
|
30
|
+
def rem_file(path, text)
|
|
31
|
+
if File.extname(path).casecmp?(".rem") || text.match?(REM_SHEBANG)
|
|
32
|
+
[Source.new(path: path, text: text)]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Remind embedded in shell heredocs. Returns nil rather than an empty array
|
|
37
|
+
# when the file holds none, so the extractor chain keeps looking.
|
|
38
|
+
def shell_heredoc(path, text)
|
|
39
|
+
sources = scan_heredocs(path, text)
|
|
40
|
+
|
|
41
|
+
unless sources.empty?
|
|
42
|
+
sources
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Ordered most-specific-first: a `.rem` file is taken whole, and only a file
|
|
47
|
+
# that is not itself Remind gets searched for embedded heredocs.
|
|
48
|
+
ALL = [method(:rem_file), method(:shell_heredoc)].freeze
|
|
49
|
+
|
|
50
|
+
# The first extractor that recognises the file wins.
|
|
51
|
+
def extract(path, text)
|
|
52
|
+
ALL.lazy.filter_map { |extractor| extractor.call(path, text) }.first || []
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def scan_heredocs(path, text)
|
|
56
|
+
lines = text.lines
|
|
57
|
+
sources = []
|
|
58
|
+
index = 0
|
|
59
|
+
|
|
60
|
+
while index < lines.length
|
|
61
|
+
match = lines[index].match(HEREDOC_OPENER)
|
|
62
|
+
|
|
63
|
+
if match
|
|
64
|
+
body_start = index + 1
|
|
65
|
+
body_end = find_terminator(
|
|
66
|
+
lines,
|
|
67
|
+
body_start,
|
|
68
|
+
match[:delimiter],
|
|
69
|
+
match[:dash] == "-",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
sources << Source.new(
|
|
73
|
+
path: path,
|
|
74
|
+
text: lines[body_start...body_end].join,
|
|
75
|
+
line_offset: body_start,
|
|
76
|
+
description: "heredoc at line #{index + 1}",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
index = body_end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
index += 1
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
sources
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# `<<-` lets the closing delimiter be indented; plain `<<` demands it sit
|
|
89
|
+
# at column zero. An unterminated heredoc runs to end of file, which is
|
|
90
|
+
# what the shell would do too.
|
|
91
|
+
def find_terminator(lines, from, delimiter, allow_indent)
|
|
92
|
+
cursor = from
|
|
93
|
+
|
|
94
|
+
while cursor < lines.length && !terminator?(lines[cursor], delimiter, allow_indent)
|
|
95
|
+
cursor += 1
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
cursor
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def terminator?(line, delimiter, allow_indent)
|
|
102
|
+
body = line.chomp
|
|
103
|
+
|
|
104
|
+
if allow_indent
|
|
105
|
+
body.sub(/\A\t+/, "") == delimiter
|
|
106
|
+
else
|
|
107
|
+
body == delimiter
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
__END__
|
|
114
|
+
|
|
115
|
+
describe "RemLint::Extractors" do
|
|
116
|
+
extract = proc { |path, text| RemLint::Extractors.extract(path, text) }
|
|
117
|
+
|
|
118
|
+
describe "plain Remind files" do
|
|
119
|
+
it "takes a .rem file whole, with no offset" do
|
|
120
|
+
sources = extract.("holidays.rem", "REM 1 Jan MSG New Year\n")
|
|
121
|
+
|
|
122
|
+
sources.length.should == 1
|
|
123
|
+
sources.first.text.should == "REM 1 Jan MSG New Year\n"
|
|
124
|
+
sources.first.line_offset.should == 0
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it "recognises the extension regardless of case" do
|
|
128
|
+
extract.("Holidays.REM", "MSG hi\n").length.should == 1
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it "recognises a Remind shebang on a file with no extension" do
|
|
132
|
+
sources = extract.("alignment", "#!/usr/bin/env -S remind -@2\nMSG hi\n")
|
|
133
|
+
|
|
134
|
+
sources.length.should == 1
|
|
135
|
+
sources.first.line_offset.should == 0
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
describe "shell heredocs" do
|
|
140
|
+
script = <<~SHELL
|
|
141
|
+
#!/bin/sh
|
|
142
|
+
# a comment
|
|
143
|
+
remind -@2 - <<'EOF'
|
|
144
|
+
BANNER %
|
|
145
|
+
MSG hello
|
|
146
|
+
EOF
|
|
147
|
+
exit 0
|
|
148
|
+
SHELL
|
|
149
|
+
|
|
150
|
+
it "extracts the heredoc body and nothing else" do
|
|
151
|
+
sources = extract.("ansitext", script)
|
|
152
|
+
|
|
153
|
+
sources.length.should == 1
|
|
154
|
+
sources.first.text.should == "BANNER %\nMSG hello\n"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
it "offsets by the number of lines before the body" do
|
|
158
|
+
# The body starts on physical line 4, so the offset is the 3 lines above.
|
|
159
|
+
extract.("ansitext", script).first.line_offset.should == 3
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it "finds every heredoc in a file that has several" do
|
|
163
|
+
four = <<~SHELL
|
|
164
|
+
#!/bin/sh
|
|
165
|
+
remind -q -@2 - "$@" <<'EOF'
|
|
166
|
+
MSG one
|
|
167
|
+
EOF
|
|
168
|
+
echo between
|
|
169
|
+
remind -q -@2 - "$@" <<'EOF'
|
|
170
|
+
MSG two
|
|
171
|
+
EOF
|
|
172
|
+
SHELL
|
|
173
|
+
|
|
174
|
+
sources = extract.("astro", four)
|
|
175
|
+
sources.length.should == 2
|
|
176
|
+
sources.map(&:text).should == ["MSG one\n", "MSG two\n"]
|
|
177
|
+
sources.map(&:line_offset).should == [2, 6]
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
it "handles an unquoted delimiter" do
|
|
181
|
+
extract.("s", "#!/bin/sh\nremind - <<EOF\nMSG hi\nEOF\n").first.text.should == "MSG hi\n"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
it "allows an indented terminator only for the <<- form" do
|
|
185
|
+
dash = "#!/bin/sh\nremind - <<-EOF\n\tMSG hi\n\tEOF\nafter\n"
|
|
186
|
+
extract.("s", dash).first.text.should == "\tMSG hi\n"
|
|
187
|
+
|
|
188
|
+
plain = "#!/bin/sh\nremind - <<EOF\nMSG hi\n\tEOF\n"
|
|
189
|
+
extract.("s", plain).first.text.should == "MSG hi\n\tEOF\n"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
it "runs an unterminated heredoc to end of file" do
|
|
193
|
+
extract.("s", "#!/bin/sh\nremind - <<'EOF'\nMSG hi\n").first.text.should == "MSG hi\n"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
it "describes which heredoc a source came from" do
|
|
197
|
+
extract.("astro", script).first.label.should == "astro (heredoc at line 3)"
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
describe "files that are not Remind at all" do
|
|
202
|
+
it "yields nothing" do
|
|
203
|
+
extract.("build.sh", "#!/bin/sh\necho hello\n").should.be.empty
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
it "does not mistake a mention of remind in a comment for a heredoc" do
|
|
207
|
+
extract.("notes.txt", "# see remind for details\n").should.be.empty
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "offense"
|
|
4
|
+
|
|
5
|
+
module RemLint
|
|
6
|
+
# Renders offences for a terminal, an editor, or a CI annotation.
|
|
7
|
+
#
|
|
8
|
+
# The default line is `path:line:column: severity: [Rule] message`, which
|
|
9
|
+
# vim's default `errorformat` already parses and which most CI annotators
|
|
10
|
+
# recognise. `--log-format` overrides it with the same `%{...}` placeholders
|
|
11
|
+
# puppet-lint uses, so a project that needs GitHub's `::error file=...`
|
|
12
|
+
# shape can have it without a new formatter class.
|
|
13
|
+
class Formatter
|
|
14
|
+
DEFAULT_TEMPLATE = "%{path}:%{line}:%{column}: %{severity}: [%{rule}] %{message}"
|
|
15
|
+
|
|
16
|
+
attr_reader :template
|
|
17
|
+
|
|
18
|
+
def initialize(template: DEFAULT_TEMPLATE)
|
|
19
|
+
@template = template
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def lines(offenses)
|
|
23
|
+
offenses.map { |offense| offense.format_with(template) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# One line per offence, then a count. Empty output for a clean run: a
|
|
27
|
+
# linter that prints "0 offences" on every commit trains people to stop
|
|
28
|
+
# reading its output.
|
|
29
|
+
def render(offenses)
|
|
30
|
+
if offenses.empty?
|
|
31
|
+
""
|
|
32
|
+
else
|
|
33
|
+
"#{(lines(offenses) + [summary(offenses)]).join("\n")}\n"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def summary(offenses)
|
|
38
|
+
counts = SEVERITIES.filter_map do |severity|
|
|
39
|
+
count = offenses.count { |offense| offense.severity == severity }
|
|
40
|
+
|
|
41
|
+
if count.positive?
|
|
42
|
+
"#{count} #{severity}#{count == 1 ? '' : 's'}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
"#{offenses.length} offence#{offenses.length == 1 ? '' : 's'} (#{counts.join(', ')})"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
__END__
|
|
52
|
+
|
|
53
|
+
describe "RemLint::Formatter" do
|
|
54
|
+
offense = proc do |overrides = {}|
|
|
55
|
+
defaults = {
|
|
56
|
+
path: "holidays.rem",
|
|
57
|
+
line: 12,
|
|
58
|
+
column: 4,
|
|
59
|
+
rule: "TrailingWhitespace",
|
|
60
|
+
message: "Trailing whitespace",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
RemLint::Offense.new(**defaults.merge(overrides))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it "renders the default line format" do
|
|
67
|
+
RemLint::Formatter.new.lines([offense.()]).should ==
|
|
68
|
+
["holidays.rem:12:4: warning: [TrailingWhitespace] Trailing whitespace"]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it "renders through a caller-supplied template" do
|
|
72
|
+
formatter = RemLint::Formatter.new(template: "::error file=%{path},line=%{line}::%{message}")
|
|
73
|
+
|
|
74
|
+
formatter.lines([offense.()]).should == ["::error file=holidays.rem,line=12::Trailing whitespace"]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it "prints nothing at all for a clean run" do
|
|
78
|
+
RemLint::Formatter.new.render([]).should == ""
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it "ends the report with a count by severity" do
|
|
82
|
+
report = RemLint::Formatter.new.render([offense.(), offense.(severity: "error")])
|
|
83
|
+
|
|
84
|
+
report.should.match(/2 offences \(1 error, 1 warning\)\n\z/)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "uses the singular for one of anything" do
|
|
88
|
+
RemLint::Formatter.new.summary([offense.()]).should == "1 offence (1 warning)"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it "lists only the severities that occurred, worst first" do
|
|
92
|
+
offenses = [offense.(severity: "info"), offense.(severity: "error")]
|
|
93
|
+
|
|
94
|
+
RemLint::Formatter.new.summary(offenses).should == "2 offences (1 error, 1 info)"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RemLint
|
|
4
|
+
# How a file is meant to be run, declared in the file.
|
|
5
|
+
#
|
|
6
|
+
# Three of the checks a Remind file wants need something that is not in the
|
|
7
|
+
# file: the command line. `-g` decides whether a sort happens, `-p` versus
|
|
8
|
+
# `-pp` decides whether `INFO` headers reach the back-end at all, and
|
|
9
|
+
# calendar mode versus agenda mode decides whether `TODO` means anything.
|
|
10
|
+
#
|
|
11
|
+
# Rather than guess three times, the file says so once:
|
|
12
|
+
#
|
|
13
|
+
# # remlint:invocation remind -pp -g /path/to/file
|
|
14
|
+
#
|
|
15
|
+
# No declaration means no opinion, and the rules that depend on it stay
|
|
16
|
+
# silent. That is the honest default: a file with no declaration is one whose
|
|
17
|
+
# invocation the linter genuinely does not know.
|
|
18
|
+
class Invocation
|
|
19
|
+
DIRECTIVE = /[#;]\s*remlint:invocation\s+(?<command>.+?)\s*\z/
|
|
20
|
+
|
|
21
|
+
# `-p`, `-pp`, `-s`, `-c` and the `a`/`+`/digit suffixes each may carry.
|
|
22
|
+
CALENDAR = /\A-(?<kind>[psc])(?<rest>[a-z+0-9]*)\z/
|
|
23
|
+
|
|
24
|
+
SORT = /\A-g(?<spec>[a-z]*)\z/
|
|
25
|
+
|
|
26
|
+
attr_reader :arguments
|
|
27
|
+
|
|
28
|
+
def initialize(arguments)
|
|
29
|
+
@arguments = arguments
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Reads the first declaration in a document, or an empty one.
|
|
33
|
+
def self.of(document)
|
|
34
|
+
declaration = document.raw_lines.filter_map { |raw| raw.match(DIRECTIVE) }.first
|
|
35
|
+
|
|
36
|
+
new(declaration ? declaration[:command].split : [])
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def declared?
|
|
40
|
+
!arguments.empty?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Any of the calendar-producing modes. Everything else is agenda mode,
|
|
44
|
+
# which is where TODO semantics live.
|
|
45
|
+
def calendar?
|
|
46
|
+
arguments.any? { |argument| argument.match?(CALENDAR) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def agenda?
|
|
50
|
+
declared? && !calendar?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# `-pp` and above carry INFO headers to the back-end; plain `-p` does not.
|
|
54
|
+
def carries_info?
|
|
55
|
+
arguments.any? { |argument| argument.match?(/\A-p{2,}/) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def simple_calendar?
|
|
59
|
+
arguments.any? { |argument| argument.match?(/\A-p/) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The `-g` spec, or nil. Remind takes up to four characters, each `a` or
|
|
63
|
+
# `d`, for date, time, priority and timedness.
|
|
64
|
+
def sort_spec
|
|
65
|
+
match = arguments.filter_map { |argument| argument.match(SORT) }.first
|
|
66
|
+
|
|
67
|
+
match && match[:spec]
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
__END__
|
|
73
|
+
|
|
74
|
+
require_relative "document"
|
|
75
|
+
|
|
76
|
+
describe "RemLint::Invocation" do
|
|
77
|
+
of = proc do |text|
|
|
78
|
+
source = RemLint::Source.new(path: "t.rem", text: text)
|
|
79
|
+
|
|
80
|
+
RemLint::Invocation.of(RemLint::Document.new(source))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it "is undeclared for a file that says nothing" do
|
|
84
|
+
of.("MSG hi\n").should.not.be.declared
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "reads a declaration from a comment" do
|
|
88
|
+
of.("# remlint:invocation remind -pp -g\nMSG hi\n").should.be.declared
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it "reads one written with a semicolon" do
|
|
92
|
+
of.("; remlint:invocation remind -pp\nMSG hi\n").should.be.declared
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it "takes the first declaration when there are two" do
|
|
96
|
+
invocation = of.("# remlint:invocation remind -p\n# remlint:invocation remind -c\nMSG hi\n")
|
|
97
|
+
|
|
98
|
+
invocation.simple_calendar?.should.be.true
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
describe "calendar and agenda mode" do
|
|
102
|
+
it "reads -c as a calendar" do
|
|
103
|
+
of.("# remlint:invocation remind -c3 file\n").should.be.calendar
|
|
104
|
+
of.("# remlint:invocation remind -c3 file\n").agenda?.should.be.false
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it "reads -p and -s as calendars" do
|
|
108
|
+
of.("# remlint:invocation remind -p file\n").should.be.calendar
|
|
109
|
+
of.("# remlint:invocation remind -sa file\n").should.be.calendar
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
it "reads anything else as agenda mode" do
|
|
113
|
+
of.("# remlint:invocation remind -q file\n").should.be.agenda
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it "has no opinion when nothing is declared" do
|
|
117
|
+
of.("MSG hi\n").agenda?.should.be.false
|
|
118
|
+
of.("MSG hi\n").calendar?.should.be.false
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
describe "INFO headers" do
|
|
123
|
+
it "knows -pp carries them" do
|
|
124
|
+
of.("# remlint:invocation remind -pp file\n").should.be.carries_info
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it "knows plain -p does not" do
|
|
128
|
+
of.("# remlint:invocation remind -p file\n").carries_info?.should.be.false
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
describe "the sort spec" do
|
|
133
|
+
it "reads the characters after -g" do
|
|
134
|
+
of.("# remlint:invocation remind -gaad file\n").sort_spec.should == "aad"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it "reads a bare -g" do
|
|
138
|
+
of.("# remlint:invocation remind -g file\n").sort_spec.should == ""
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it "is nil when there is no -g" do
|
|
142
|
+
of.("# remlint:invocation remind -q file\n").sort_spec.should.be.nil
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|