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,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