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,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+ require_relative "../expr_lexer"
5
+
6
+ module RemLint
7
+ module Rules
8
+ # `$Latitude` or `$Longitude` set to something that is not a string.
9
+ #
10
+ # Remind has no floating-point type. A coordinate therefore has to arrive
11
+ # as a STRING and be parsed from it -- `SET $Latitude "45.42"`, with the
12
+ # quotes. `SET $Latitude 45.42` is not a near miss with a rounding error in
13
+ # it; `45.42` is not a Remind literal at all.
14
+ #
15
+ # `examples/astro` gets this right the hard way, threading the values in
16
+ # from the shell as strings:
17
+ #
18
+ # remind -g "-i\$Latitude=\"$latitude\"" ...
19
+ #
20
+ # The failure is quiet and confident. Remind falls back or truncates, and
21
+ # then reports sunrise and sunset times that look entirely plausible and
22
+ # are for somewhere else.
23
+ class CoordinateNotString < Rule
24
+ COORDINATES = %w[latitude longitude].freeze
25
+
26
+ # `SET $Latitude <value>`, with the value running to end of line.
27
+ ASSIGNMENT = /\A\$(?<name>\w+)\s+(?<value>.+)\z/m
28
+
29
+ def self.default_severity
30
+ "error"
31
+ end
32
+
33
+ def self.description
34
+ "$Latitude or $Longitude set to something other than a string."
35
+ end
36
+
37
+ def check
38
+ document.code_commands.each do |command|
39
+ if command.keyword?("SET")
40
+ check_assignment(command)
41
+ end
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def check_assignment(command)
48
+ match = command.args.match(ASSIGNMENT)
49
+
50
+ if match && COORDINATES.include?(match[:name].downcase)
51
+ check_value(command, match)
52
+ end
53
+ end
54
+
55
+ def check_value(command, match)
56
+ value = match[:value].strip
57
+ tokens = ExprLexer.significant(value)
58
+
59
+ if literal_non_string?(tokens)
60
+ offend(
61
+ command.line,
62
+ "`$#{match[:name]}` takes a STRING -- Remind has no floating-point " \
63
+ "type, so write `\"#{value}\"`",
64
+ column: command.keyword_column,
65
+ )
66
+ end
67
+ end
68
+
69
+ # Only a literal is reported. A single string token is right; a single
70
+ # bracketed or computed value cannot be judged without running it, and
71
+ # `[latitude_from_gps()]` is a perfectly good way to set this.
72
+ def literal_non_string?(tokens)
73
+ types = tokens.map(&:type)
74
+
75
+ types.first != :string &&
76
+ !types.include?(:lbracket) &&
77
+ !types.include?(:function) &&
78
+ !types.include?(:name)
79
+ end
80
+ end
81
+ end
82
+ end
83
+
84
+ __END__
85
+
86
+ require_relative "../document"
87
+
88
+ describe "RemLint::Rules::CoordinateNotString" do
89
+ lint = proc do |text|
90
+ source = RemLint::Source.new(path: "t.rem", text: text)
91
+
92
+ RemLint::Rules::CoordinateNotString.new.run(RemLint::Document.new(source))
93
+ end
94
+
95
+ messages = proc { |text| lint.(text).map(&:message) }
96
+
97
+ describe "coordinates written as numbers" do
98
+ it "reports a bare decimal" do
99
+ messages.("SET $Latitude 45.42\n").first.should ==
100
+ %(`$Latitude` takes a STRING -- Remind has no floating-point type, so write `"45.42"`)
101
+ end
102
+
103
+ it "reports a negative decimal" do
104
+ messages.("SET $Longitude -75.69\n").length.should == 1
105
+ end
106
+
107
+ it "reports a bare integer" do
108
+ messages.("SET $Latitude 45\n").length.should == 1
109
+ end
110
+
111
+ it "reports both coordinates" do
112
+ messages.("SET $Latitude 45.42\nSET $Longitude -75.69\n").length.should == 2
113
+ end
114
+
115
+ it "matches the variable name case-insensitively" do
116
+ messages.("SET $latitude 45.42\n").length.should == 1
117
+ messages.("SET $LONGITUDE 45.42\n").length.should == 1
118
+ end
119
+
120
+ it "points at the keyword" do
121
+ lint.(" SET $Latitude 45.42\n").first.column.should == 4
122
+ end
123
+ end
124
+
125
+ describe "coordinates written correctly" do
126
+ it "accepts a double-quoted string" do
127
+ lint.(%(SET $Latitude "45.42"\n)).should.be.empty
128
+ end
129
+
130
+ it "accepts a negative value in a string" do
131
+ lint.(%(SET $Longitude "-75.69"\n)).should.be.empty
132
+ end
133
+
134
+ it "accepts the degrees-minutes-seconds string form" do
135
+ lint.(%(SET $Latitude "45 25 12"\n)).should.be.empty
136
+ end
137
+ end
138
+
139
+ describe "values it cannot judge" do
140
+ it "says nothing about a pasted expression" do
141
+ lint.("SET $Latitude [gps_latitude()]\n").should.be.empty
142
+ end
143
+
144
+ it "says nothing about a function call" do
145
+ lint.("SET $Latitude coerce(\"STRING\", x)\n").should.be.empty
146
+ end
147
+
148
+ it "says nothing about a variable" do
149
+ # examples/astro feeds these in with remind -i$Latitude="..."
150
+ lint.("SET $Latitude mylat\n").should.be.empty
151
+ end
152
+ end
153
+
154
+ describe "what it leaves alone" do
155
+ it "says nothing about other system variables" do
156
+ lint.("SET $FormWidth 80\n").should.be.empty
157
+ end
158
+
159
+ it "says nothing about an ordinary variable of the same name" do
160
+ lint.("SET Latitude 45.42\n").should.be.empty
161
+ end
162
+
163
+ it "says nothing about a command that is not SET" do
164
+ lint.("MSG Latitude is [$Latitude]\n").should.be.empty
165
+ end
166
+
167
+ it "says nothing about comments" do
168
+ lint.("# SET $Latitude 45.42\n").should.be.empty
169
+ end
170
+ end
171
+
172
+ it "reports at error severity" do
173
+ lint.("SET $Latitude 45.42\n").first.severity.should == "error"
174
+ end
175
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module RemLint
6
+ module Rules
7
+ # Backslashes that look like line continuations and are not.
8
+ #
9
+ # Remind continues a line only when the backslash is the final character of
10
+ # it (src/files.c: `if (l && (DBufValue(&buf)[l-1] == '\\'))`). One
11
+ # invisible space after the backslash and the continuation silently stops
12
+ # being one -- the command is cut in half, the first half usually still
13
+ # parses as something, and the error surfaces somewhere else entirely.
14
+ #
15
+ # Both halves of that are worth reporting:
16
+ #
17
+ # backslash then whitespace the join you meant did not happen
18
+ # backslash at end of file the command you opened never closed
19
+ class DanglingContinuation < Rule
20
+ SWALLOWED = /\\[ \t]+\z/
21
+
22
+ SWALLOWED_MESSAGE =
23
+ "Backslash followed by whitespace does not continue the line; " \
24
+ "the backslash must be the last character"
25
+
26
+ UNTERMINATED_MESSAGE = "File ends inside a line continuation"
27
+
28
+ def self.default_severity
29
+ "error"
30
+ end
31
+
32
+ def self.description
33
+ "A backslash that looks like a line continuation but is not one."
34
+ end
35
+
36
+ def check
37
+ check_swallowed
38
+ check_unterminated
39
+ end
40
+
41
+ private
42
+
43
+ def check_swallowed
44
+ document.each_raw_line do |raw, line|
45
+ body = raw.chomp
46
+ match = body.match(SWALLOWED)
47
+
48
+ if match
49
+ offend(line, SWALLOWED_MESSAGE, column: match.begin(0) + 1)
50
+ end
51
+ end
52
+ end
53
+
54
+ # The joiner emits a command left open at end of file rather than
55
+ # dropping it, precisely so this can be said out loud.
56
+ def check_unterminated
57
+ last = document.logical_lines.last
58
+
59
+ if last && last.raw.chomp.end_with?("\\")
60
+ offend(last.last_line, UNTERMINATED_MESSAGE)
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ __END__
68
+
69
+ require_relative "../document"
70
+
71
+ describe "RemLint::Rules::DanglingContinuation" do
72
+ lint = proc do |text|
73
+ source = RemLint::Source.new(path: "t.rem", text: text)
74
+
75
+ RemLint::Rules::DanglingContinuation.new.run(RemLint::Document.new(source))
76
+ end
77
+
78
+ describe "a backslash followed by whitespace" do
79
+ it "is reported" do
80
+ offenses = lint.("SET x 1 + \\ \n2\n")
81
+
82
+ offenses.length.should == 1
83
+ offenses.first.line.should == 1
84
+ offenses.first.message.should.match(/does not continue/)
85
+ end
86
+
87
+ it "points at the backslash, not at the whitespace" do
88
+ lint.("SET x 1 + \\ \n2\n").first.column.should == 11
89
+ end
90
+
91
+ it "is reported for a trailing tab too" do
92
+ lint.("SET x 1 + \\\t\n2\n").length.should == 1
93
+ end
94
+
95
+ it "reports at error severity" do
96
+ lint.("SET x \\ \n").first.severity.should == "error"
97
+ end
98
+ end
99
+
100
+ describe "a real continuation" do
101
+ it "is left alone" do
102
+ lint.("SET x 1 + \\\n 2\n").should.be.empty
103
+ end
104
+
105
+ it "is left alone even several lines deep" do
106
+ lint.("SET x IIF(a, \\\n b, \\\n c)\n").should.be.empty
107
+ end
108
+ end
109
+
110
+ describe "a file that ends inside a continuation" do
111
+ it "is reported on the last line" do
112
+ offenses = lint.("MSG hi\nSET x 1 + \\\n")
113
+
114
+ offenses.map(&:message).should == ["File ends inside a line continuation"]
115
+ offenses.first.line.should == 2
116
+ end
117
+
118
+ it "is reported even when the file has no trailing newline" do
119
+ lint.("SET x 1 + \\").length.should == 1
120
+ end
121
+
122
+ it "reports the last physical line of a multi-line command" do
123
+ lint.("SET x 1 + \\\n 2 + \\\n").first.line.should == 2
124
+ end
125
+ end
126
+
127
+ it "says nothing about a file with no backslashes at all" do
128
+ lint.("MSG one\nMSG two\n").should.be.empty
129
+ end
130
+
131
+ it "says nothing about a backslash in the middle of a line" do
132
+ lint.(%(MSG a \\" b\n)).should.be.empty
133
+ end
134
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+ require_relative "../date_literal"
5
+
6
+ module RemLint
7
+ module Rules
8
+ # Dates outside the range Remind can represent.
9
+ #
10
+ # Remind counts days from `BASE`, which is 1990, and represents
11
+ # `YR_RANGE` years, which is 4000 (src/custom.h.in). So 1990-01-01 to
12
+ # 5990-12-31 is not a policy, it is the type. A date below the floor cannot
13
+ # be stored at all.
14
+ #
15
+ # Three of the book's rules asked for a slice of this each --
16
+ # `TriggerComponentRange`, `DateConstantBeforeEpoch`,
17
+ # `DateOutsideRepresentableRange` -- and Appendix B's own note suggested
18
+ # collecting them. One rule keeps the message identical wherever the date
19
+ # was written.
20
+ #
21
+ # Two shapes are checked: a spelled-out date in a trigger, and a
22
+ # single-quoted date constant in an expression. A day of 32 or a month
23
+ # nobody has is caught by the same walk.
24
+ class DateOutOfRange < Rule
25
+ FIRST_YEAR = DateLiteral::EPOCH_YEAR
26
+ LAST_YEAR = DateLiteral::LAST_YEAR
27
+
28
+ # `'1970-01-01'`, `'2027-13-01'` -- a date constant, which the lexer
29
+ # hands over whole.
30
+ CONSTANT = %r{\A'(?<year>\d{4})[-/](?<month>\d{1,2})[-/](?<day>\d{1,2})}
31
+
32
+ DAYS_IN_MONTH = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31].freeze
33
+
34
+ def self.default_severity
35
+ "error"
36
+ end
37
+
38
+ def self.description
39
+ "A date outside 1990-01-01 to 5990-12-31, or with an impossible component."
40
+ end
41
+
42
+ def check
43
+ document.logical_lines.each_with_index do |logical_line, index|
44
+ if document.commands[index].code?
45
+ check_constants(logical_line)
46
+ end
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def check_constants(logical_line)
53
+ document.tokens_for(logical_line).each do |token|
54
+ match = token.type == :string && token.value.match(CONSTANT)
55
+
56
+ if match
57
+ report(logical_line, token, match)
58
+ end
59
+ end
60
+ end
61
+
62
+ def report(logical_line, token, match)
63
+ year = match[:year].to_i
64
+ month = match[:month].to_i
65
+ day = match[:day].to_i
66
+ complaint = fault(year, month, day)
67
+
68
+ if complaint
69
+ offend_at(logical_line, token.offset, "`#{token.value}` #{complaint}")
70
+ end
71
+ end
72
+
73
+ def fault(year, month, day)
74
+ if year < FIRST_YEAR
75
+ "is before #{FIRST_YEAR}-01-01, which is the earliest date Remind represents"
76
+ elsif year > LAST_YEAR
77
+ "is after #{LAST_YEAR}-12-31, which is the latest date Remind represents"
78
+ else
79
+ component_fault(month, day)
80
+ end
81
+ end
82
+
83
+ def component_fault(month, day)
84
+ if month < 1 || month > 12
85
+ "has month #{month}"
86
+ elsif day < 1 || day > DAYS_IN_MONTH[month - 1]
87
+ "has day #{day} in a month with #{DAYS_IN_MONTH[month - 1]}"
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+
94
+ __END__
95
+
96
+ require_relative "../document"
97
+
98
+ describe "RemLint::Rules::DateOutOfRange" do
99
+ lint = proc do |text|
100
+ source = RemLint::Source.new(path: "t.rem", text: text)
101
+
102
+ RemLint::Rules::DateOutOfRange.new.run(RemLint::Document.new(source))
103
+ end
104
+
105
+ messages = proc { |text| lint.(text).map(&:message) }
106
+
107
+ describe "dates Remind cannot represent" do
108
+ it "reports one before the epoch" do
109
+ messages.("SET a '1970-01-01'\n").first.should ==
110
+ "`'1970-01-01'` is before 1990-01-01, which is the earliest date Remind represents"
111
+ end
112
+
113
+ it "reports one after the last year" do
114
+ messages.("SET a '9999-01-01'\n").first.should.match(/is after 5990-12-31/)
115
+ end
116
+
117
+ it "accepts the first representable date" do
118
+ lint.("SET a '1990-01-01'\n").should.be.empty
119
+ end
120
+
121
+ it "accepts the last representable year" do
122
+ lint.("SET a '5990-12-31'\n").should.be.empty
123
+ end
124
+ end
125
+
126
+ describe "impossible components" do
127
+ it "reports a month of 13" do
128
+ messages.("SET a '2027-13-01'\n").first.should.match(/has month 13/)
129
+ end
130
+
131
+ it "reports a month of 0" do
132
+ messages.("SET a '2027-00-01'\n").first.should.match(/has month 0/)
133
+ end
134
+
135
+ it "reports a day past the length of its month" do
136
+ messages.("SET a '2027-02-30'\n").first.should.match(/has day 30 in a month with 29/)
137
+ messages.("SET a '2027-04-31'\n").first.should.match(/has day 31 in a month with 30/)
138
+ end
139
+
140
+ it "accepts 29 February, which some years have" do
141
+ lint.("SET a '2028-02-29'\n").should.be.empty
142
+ end
143
+
144
+ it "accepts the last day of a 31-day month" do
145
+ lint.("SET a '2027-01-31'\n").should.be.empty
146
+ end
147
+ end
148
+
149
+ it "accepts the slash separator" do
150
+ lint.("SET a '2027/01/31'\n").should.be.empty
151
+ messages.("SET a '1970/01/01'\n").length.should == 1
152
+ end
153
+
154
+ it "reports every bad constant on a line" do
155
+ messages.("IF ['1970-01-01' < '9999-01-01']\n").length.should == 2
156
+ end
157
+
158
+ it "says nothing about a datetime constant in range" do
159
+ lint.("SET a '2027-01-01@14:33'\n").should.be.empty
160
+ end
161
+
162
+ it "says nothing about an ordinary string that looks like a date" do
163
+ # Double quotes make it a STRING, not a DATE.
164
+ lint.(%(SET a "1970-01-01"\n)).should.be.empty
165
+ end
166
+
167
+ it "says nothing about comments" do
168
+ lint.("# SET a '1970-01-01'\n").should.be.empty
169
+ end
170
+
171
+ it "points at the constant" do
172
+ text = "SET a '1970-01-01'\n"
173
+
174
+ lint.(text).first.column.should == text.index("'") + 1
175
+ end
176
+
177
+ it "reports at error severity" do
178
+ lint.("SET a '1970-01-01'\n").first.severity.should == "error"
179
+ end
180
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module RemLint
6
+ module Rules
7
+ # `DEBUG` flags that do nothing, and `DEBUG` flags left switched on.
8
+ #
9
+ # `DoDebug` in src/main.c reads the argument character by character: `+`
10
+ # turns flags on, `-` turns them off, and thirteen letters name a flag.
11
+ # Anything else reaches a `default:` branch that warns -- but only at
12
+ # warning level 05.02.03, so a script that has not raised the level gets a
13
+ # debug session that quietly does nothing.
14
+ #
15
+ # The other half matters more in a repository than at a terminal. Debug
16
+ # output is voluminous and goes to standard error, so a `DEBUG +x` that was
17
+ # never matched by a `DEBUG -x` turns every later run into a trace log --
18
+ # including the one under cron, which mails it to somebody.
19
+ class DebugCommand < Rule
20
+ # The flag letters `DoDebug` switches on, case-insensitive.
21
+ FLAGS = %w[p e q s h x t v l f n u z].freeze
22
+
23
+ SIGNS = %w[+ -].freeze
24
+
25
+ def self.default_severity
26
+ "warning"
27
+ end
28
+
29
+ def self.description
30
+ "A DEBUG flag Remind does not define, or one switched on and never off."
31
+ end
32
+
33
+ def check
34
+ left_on = {}
35
+
36
+ document.code_commands.each do |command|
37
+ if command.keyword?("DEBUG")
38
+ scan(command, left_on)
39
+ end
40
+ end
41
+
42
+ report_left_on(left_on)
43
+ end
44
+
45
+ private
46
+
47
+ def scan(command, left_on)
48
+ sign = "+"
49
+
50
+ command.args.each_char.with_index do |character, index|
51
+ sign = step(
52
+ command,
53
+ character,
54
+ sign,
55
+ index,
56
+ left_on,
57
+ )
58
+ end
59
+ end
60
+
61
+ def step(command, character, sign, index, left_on)
62
+ if SIGNS.include?(character)
63
+ character
64
+ elsif character.match?(/\s/)
65
+ sign
66
+ else
67
+ record(
68
+ command,
69
+ character,
70
+ sign,
71
+ index,
72
+ left_on,
73
+ )
74
+ sign
75
+ end
76
+ end
77
+
78
+ def record(command, character, sign, index, left_on)
79
+ if FLAGS.include?(character.downcase)
80
+ track(
81
+ command,
82
+ character.downcase,
83
+ sign,
84
+ left_on,
85
+ )
86
+ else
87
+ report_unknown(command, character, index)
88
+ end
89
+ end
90
+
91
+ def track(command, flag, sign, left_on)
92
+ if sign == "+"
93
+ left_on[flag] ||= command
94
+ else
95
+ left_on.delete(flag)
96
+ end
97
+ end
98
+
99
+ def report_unknown(command, character, index)
100
+ offend(
101
+ command.line,
102
+ "`#{character}` is not a DEBUG flag; Remind defines #{FLAGS.join(', ')}",
103
+ column: command.args_offset + index + 1,
104
+ )
105
+ end
106
+
107
+ def report_left_on(left_on)
108
+ left_on.each do |flag, command|
109
+ offend(
110
+ command.line,
111
+ "`DEBUG +#{flag}` is never matched by a `DEBUG -#{flag}`; debug output " \
112
+ "goes to standard error on every later run, including the one under cron",
113
+ column: command.keyword_column,
114
+ )
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
120
+
121
+ __END__
122
+
123
+ require_relative "../document"
124
+
125
+ describe "RemLint::Rules::DebugCommand" do
126
+ lint = proc do |text|
127
+ source = RemLint::Source.new(path: "t.rem", text: text)
128
+
129
+ RemLint::Rules::DebugCommand.new.run(RemLint::Document.new(source))
130
+ end
131
+
132
+ messages = proc { |text| lint.(text).map(&:message) }
133
+
134
+ describe "unknown flags" do
135
+ it "reports a letter Remind does not define" do
136
+ messages.("DEBUG +w\nDEBUG -w\n").first.should.match(/`w` is not a DEBUG flag/)
137
+ end
138
+
139
+ it "lists the flags Remind does define" do
140
+ messages.("DEBUG +w\nDEBUG -w\n").first.should.match(/p, e, q, s, h, x, t, v, l, f, n, u, z/)
141
+ end
142
+
143
+ it "accepts every flag Remind defines" do
144
+ lint.("DEBUG +peqshxtvlfnuz\nDEBUG -peqshxtvlfnuz\n").should.be.empty
145
+ end
146
+
147
+ it "accepts them in upper case" do
148
+ lint.("DEBUG +X\nDEBUG -X\n").should.be.empty
149
+ end
150
+ end
151
+
152
+ describe "flags left on" do
153
+ it "reports a flag switched on and never off" do
154
+ messages.("DEBUG +x\n").first.should.match(/never matched by a `DEBUG -x`/)
155
+ end
156
+
157
+ it "accepts a flag switched on and then off" do
158
+ lint.("DEBUG +x\nMSG hi\nDEBUG -x\n").should.be.empty
159
+ end
160
+
161
+ it "accepts several flags switched on and off together" do
162
+ lint.("DEBUG +xt\nMSG hi\nDEBUG -xt\n").should.be.empty
163
+ end
164
+
165
+ it "reports each flag left on" do
166
+ lint.("DEBUG +xt\n").length.should == 2
167
+ end
168
+
169
+ it "handles a sign that applies to several letters" do
170
+ lint.("DEBUG +xt -x\nDEBUG -t\n").should.be.empty
171
+ end
172
+
173
+ it "reports the line the flag was switched on" do
174
+ lint.("MSG hi\nDEBUG +x\n").first.line.should == 2
175
+ end
176
+ end
177
+
178
+ it "says nothing about a file with no DEBUG at all" do
179
+ lint.("MSG hi\n").should.be.empty
180
+ end
181
+
182
+ it "says nothing about comments" do
183
+ lint.("# DEBUG +w\n").should.be.empty
184
+ end
185
+
186
+ it "reports at warning severity" do
187
+ lint.("DEBUG +x\n").first.severity.should == "warning"
188
+ end
189
+ end