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,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module RemLint
6
+ module Rules
7
+ # A language pack that never translates `LANGID`.
8
+ #
9
+ # Scripts branch on `_("LANGID")` to pick language-specific behaviour, and
10
+ # an untranslated LANGID reverts to `"en"`. So a fully translated pack that
11
+ # never translates LANGID sends every one of those scripts down the English
12
+ # path -- and does it by producing English, which reads as "not translated
13
+ # yet" rather than "translated and ignored".
14
+ #
15
+ # A pack is recognised by its day and month names, which is what
16
+ # `include/lang/*.rem` set and what nothing else does. A file translating a
17
+ # handful of strings -- `include/translations/de/sun.rem` is two lines --
18
+ # is not a pack and has no LANGID to translate.
19
+ class LocalizationPack < Rule
20
+ LANGID = "LANGID"
21
+
22
+ # What a language pack sets and a string file does not.
23
+ PACK_NAMES = (
24
+ %w[monday tuesday wednesday thursday friday saturday sunday] +
25
+ %w[january february march april may june july
26
+ august september october november december
27
+ ]
28
+ ).freeze
29
+
30
+ TRANSLATED = /\A\s*"(?<original>(?:[^"\\]|\\.)*)"\s*"/
31
+
32
+ SYSVAR_SET = /\A\$(?<name>\w+)\s/
33
+
34
+ def self.default_severity
35
+ "info"
36
+ end
37
+
38
+ def self.description
39
+ "A language pack that never translates LANGID, which reverts to English."
40
+ end
41
+
42
+ def check
43
+ if pack?
44
+ check_langid
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ # A pack names the days and months. `include/lang/de.rem` writes
51
+ # `SET $Monday "Montag"`; a translation file writes neither.
52
+ def pack?
53
+ document.code_commands.any? { |command| names_a_day_or_month?(command) }
54
+ end
55
+
56
+ def names_a_day_or_month?(command)
57
+ if command.keyword?("SET")
58
+ match = command.args.match(SYSVAR_SET)
59
+
60
+ !match.nil? && PACK_NAMES.include?(match[:name].downcase)
61
+ elsif command.keyword?("TRANSLATE")
62
+ original = command.args.match(TRANSLATED)&.[](:original)
63
+
64
+ PACK_NAMES.include?(original.to_s.downcase)
65
+ end
66
+ end
67
+
68
+ def check_langid
69
+ unless translates_langid?
70
+ offend(
71
+ document.line_number_at(0),
72
+ "this pack never translates `#{LANGID}`, which reverts to `\"en\"` -- so " \
73
+ "every script branching on `_(\"#{LANGID}\")` takes the English path",
74
+ )
75
+ end
76
+ end
77
+
78
+ def translates_langid?
79
+ document.code_commands.any? do |command|
80
+ command.keyword?("TRANSLATE") &&
81
+ command.args.match(TRANSLATED)&.[](:original) == LANGID
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ __END__
89
+
90
+ require_relative "../document"
91
+
92
+ describe "RemLint::Rules::LocalizationPack" do
93
+ lint = proc do |text|
94
+ source = RemLint::Source.new(path: "t.rem", text: text)
95
+
96
+ RemLint::Rules::LocalizationPack.new.run(RemLint::Document.new(source))
97
+ end
98
+
99
+ messages = proc { |text| lint.(text).map(&:message) }
100
+
101
+ describe "LANGID" do
102
+ it "reports a pack that never translates it" do
103
+ messages.(%(TRANSLATE "Monday" "Montag"\n)).first.should.match(
104
+ /never translates `LANGID`, which reverts to `"en"`/,
105
+ )
106
+ end
107
+
108
+ it "accepts a pack that does" do
109
+ text = %(TRANSLATE "LANGID" "de"\nTRANSLATE "Monday" "Montag"\n)
110
+
111
+ lint.(text).should.be.empty
112
+ end
113
+
114
+ it "says nothing about a file that translates nothing" do
115
+ lint.("REM 1 Jan MSG hi\n").should.be.empty
116
+ end
117
+
118
+ it "recognises a pack by the day and month names it sets" do
119
+ # include/lang/de.rem writes SET $Monday "Montag" rather than TRANSLATE.
120
+ messages.(%(SET $Monday "Montag"\n)).length.should == 1
121
+ lint.(%(SET $Monday "Montag"\nTRANSLATE "LANGID" "de"\n)).should.be.empty
122
+ end
123
+
124
+ it "does not count a file that translates a handful of strings" do
125
+ # include/translations/de/sun.rem is exactly this, and is not a pack.
126
+ lint.(%(TRANSLATE "Sunrise" "Sonnenaufgang"\nTRANSLATE "Sunset" "Sonnenuntergang"\n))
127
+ .should.be.empty
128
+ end
129
+
130
+ it "explains the consequence" do
131
+ messages.(%(TRANSLATE "Monday" "Montag"\n)).first.should.match(
132
+ /takes the English path/,
133
+ )
134
+ end
135
+ end
136
+
137
+ it "says nothing about comments" do
138
+ lint.(%(# TRANSLATE "Monday" "Montag"\n)).should.be.empty
139
+ end
140
+
141
+ it "reports at info severity" do
142
+ lint.(%(TRANSLATE "Monday" "Montag"\n)).first.severity.should == "info"
143
+ end
144
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module RemLint
6
+ module Rules
7
+ # Moon and season selectors outside their four-element sets.
8
+ #
9
+ # `moondate`, `moondatetime` and `moontime` take a phase: 0 new, 1 first
10
+ # quarter, 2 full, 3 last quarter. `soleq` takes an event: 0 March equinox,
11
+ # 1 June solstice, 2 September equinox, 3 December solstice. Both are
12
+ # closed sets of four, so a literal 4 is always wrong and always visible
13
+ # without running anything.
14
+ #
15
+ # Remind agrees -- `moondate(4)` is `Number too high` -- but says so on the
16
+ # day the line is reached, which for a moon reminder is the day it was
17
+ # supposed to fire.
18
+ class MoonPhaseArgument < Rule
19
+ PHASES = {
20
+ "moondate" => "a moon phase",
21
+ "moondatetime" => "a moon phase",
22
+ "moontime" => "a moon phase",
23
+ "soleq" => "a solstice or equinox",
24
+ }.freeze
25
+
26
+ MEANINGS = {
27
+ "a moon phase" => "0 new, 1 first quarter, 2 full, 3 last quarter",
28
+ "a solstice or equinox" => "0 March equinox, 1 June solstice, " \
29
+ "2 September equinox, 3 December solstice",
30
+ }.freeze
31
+
32
+ LIMIT = 3
33
+
34
+ def self.default_severity
35
+ "error"
36
+ end
37
+
38
+ def self.description
39
+ "A moon phase or solstice selector outside the four values it accepts."
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_line(logical_line)
46
+ end
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def check_line(logical_line)
53
+ tokens = document.tokens_for(logical_line)
54
+
55
+ tokens.each_index do |index|
56
+ selector = literal_selector(tokens, index)
57
+
58
+ if selector
59
+ report(logical_line, tokens[index], selector)
60
+ end
61
+ end
62
+ end
63
+
64
+ # `name ( <number>` -- the selector is always the first argument.
65
+ def literal_selector(tokens, index)
66
+ token = tokens[index]
67
+ argument = tokens[index + 2]
68
+
69
+ if token.type == :function && PHASES.key?(token.value.downcase) &&
70
+ tokens[index + 1]&.type == :lparen && argument&.type == :number
71
+ argument
72
+ end
73
+ end
74
+
75
+ def report(logical_line, name, argument)
76
+ value = argument.value.to_i
77
+
78
+ if value > LIMIT
79
+ kind = PHASES.fetch(name.value.downcase)
80
+
81
+ offend_at(logical_line, argument.offset, message(name.value, kind, value))
82
+ end
83
+ end
84
+
85
+ def message(name, kind, value)
86
+ "`#{name}(#{value})` is outside the four values #{kind} takes " \
87
+ "(#{MEANINGS.fetch(kind)}); Remind answers `Number too high`"
88
+ end
89
+ end
90
+ end
91
+ end
92
+
93
+ __END__
94
+
95
+ require_relative "../document"
96
+
97
+ describe "RemLint::Rules::MoonPhaseArgument" do
98
+ lint = proc do |text|
99
+ source = RemLint::Source.new(path: "t.rem", text: text)
100
+
101
+ RemLint::Rules::MoonPhaseArgument.new.run(RemLint::Document.new(source))
102
+ end
103
+
104
+ messages = proc { |text| lint.(text).map(&:message) }
105
+
106
+ describe "selectors that exist" do
107
+ it "accepts the four moon phases" do
108
+ # include/moonphases.rem writes exactly these.
109
+ (0..3).each do |phase|
110
+ lint.("REM [moondatetime(#{phase})] MSG Moon\n").should.be.empty
111
+ end
112
+ end
113
+
114
+ it "accepts the four solstice and equinox events" do
115
+ (0..3).each do |event|
116
+ lint.("REM [datepart(soleq(#{event}, $U))] MSG Season\n").should.be.empty
117
+ end
118
+ end
119
+
120
+ it "accepts moondate and moontime too" do
121
+ lint.("MSG [moondate(2)] [moontime(2)]\n").should.be.empty
122
+ end
123
+ end
124
+
125
+ describe "selectors that do not" do
126
+ it "reports a moon phase of 4" do
127
+ messages.("REM [moondatetime(4)] MSG Moon\n").first.should ==
128
+ "`moondatetime(4)` is outside the four values a moon phase takes " \
129
+ "(0 new, 1 first quarter, 2 full, 3 last quarter); Remind answers `Number too high`"
130
+ end
131
+
132
+ it "reports a solstice selector of 4" do
133
+ messages.("MSG [soleq(4, $U)]\n").first.should.match(/a solstice or equinox takes/)
134
+ end
135
+
136
+ it "reports each bad selector on a line" do
137
+ messages.("MSG [moondate(4)] [moondate(9)]\n").length.should == 2
138
+ end
139
+
140
+ it "points at the argument" do
141
+ text = "MSG [moondate(4)]\n"
142
+
143
+ lint.(text).first.column.should == text.index("4") + 1
144
+ end
145
+ end
146
+
147
+ it "says nothing about a computed selector" do
148
+ lint.("MSG [moondate(n)]\n").should.be.empty
149
+ end
150
+
151
+ it "says nothing about another function taking a large number" do
152
+ lint.("MSG [max(4, 9)]\n").should.be.empty
153
+ end
154
+
155
+ it "says nothing about comments" do
156
+ lint.("# MSG [moondate(4)]\n").should.be.empty
157
+ end
158
+
159
+ it "reports at error severity" do
160
+ lint.("MSG [moondate(4)]\n").first.severity.should == "error"
161
+ end
162
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module RemLint
6
+ module Rules
7
+ # A single-sign delta in a file with nothing omitted.
8
+ #
9
+ # `++n` counts calendar days back from the event; `+n` counts *non-omitted*
10
+ # days. The distinction is the whole point of the single-sign form, and it
11
+ # is exactly nothing when the omit context is empty: with no `OMIT`
12
+ # anywhere, `+n` and `++n` produce identical reminders.
13
+ #
14
+ # So a single sign in a file with no omits is one of two things, and both
15
+ # are worth a look: a `+` that was meant to be `++`, or a reminder that
16
+ # depends on an `OMIT` somebody forgot to write. The same holds for the
17
+ # single-tilde back, `~n`.
18
+ #
19
+ # Off by default. A file that gets its omits from an `INCLUDE` is correct
20
+ # and this rule cannot see that -- which is why the check also stays quiet
21
+ # in any file that includes anything.
22
+ class OmitAwareDelta < Rule
23
+ # `+3` and `~3`, but not `++3` or `~~3`, and not an arithmetic `+`.
24
+ SINGLE = /(?<![+~\d\w])(?<sign>[+~])(?![+~])(?<count>\d+)(?!\S)/
25
+
26
+ # `OMIT` is a command; `ADDOMIT` and `OMITFUNC` are clauses on a `REM`,
27
+ # so both places have to be looked at.
28
+ OMIT_COMMANDS = %w[OMIT].freeze
29
+
30
+ OMIT_CLAUSES = %w[ADDOMIT OMITFUNC].freeze
31
+
32
+ INCLUDE_COMMANDS = %w[INCLUDE SYSINCLUDE INCLUDECMD DO].freeze
33
+
34
+ def self.enabled_by_default?
35
+ false
36
+ end
37
+
38
+ def self.default_severity
39
+ "info"
40
+ end
41
+
42
+ def self.description
43
+ "A single-sign delta or back in a file that omits nothing."
44
+ end
45
+
46
+ def check
47
+ if omits? || includes?
48
+ nil
49
+ else
50
+ report_deltas
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def omits?
57
+ document.code_commands.any? do |command|
58
+ command.keyword?(*OMIT_COMMANDS) ||
59
+ document.trigger_for(command).include?(*OMIT_CLAUSES)
60
+ end
61
+ end
62
+
63
+ # A file that includes something may be getting its omits from there.
64
+ def includes?
65
+ document.code_commands.any? { |command| command.keyword?(*INCLUDE_COMMANDS) }
66
+ end
67
+
68
+ def report_deltas
69
+ document.code_commands.each do |command|
70
+ trigger = document.trigger_for(command)
71
+
72
+ if trigger.triggered?
73
+ scan(command, trigger)
74
+ end
75
+ end
76
+ end
77
+
78
+ def scan(command, trigger)
79
+ limit = trigger.body_offset || command.text.length
80
+ head = command.text[0...limit].to_s
81
+
82
+ head.to_enum(:scan, SINGLE).each do
83
+ match = Regexp.last_match
84
+
85
+ offend_at(command.logical_line, match.begin(0), message(match))
86
+ end
87
+ end
88
+
89
+ def message(match)
90
+ sign = match[:sign]
91
+ doubled = sign * 2
92
+
93
+ "`#{sign}#{match[:count]}` counts non-omitted days, and this file omits " \
94
+ "nothing -- so it is exactly `#{doubled}#{match[:count]}`; either the sign " \
95
+ "was meant to be doubled or an `OMIT` is missing"
96
+ end
97
+ end
98
+ end
99
+ end
100
+
101
+ __END__
102
+
103
+ require_relative "../document"
104
+
105
+ describe "RemLint::Rules::OmitAwareDelta" do
106
+ lint = proc do |text|
107
+ source = RemLint::Source.new(path: "t.rem", text: text)
108
+
109
+ RemLint::Rules::OmitAwareDelta.new.run(RemLint::Document.new(source))
110
+ end
111
+
112
+ messages = proc { |text| lint.(text).map(&:message) }
113
+
114
+ it "is off unless the configuration asks for it" do
115
+ RemLint::Rules::OmitAwareDelta.enabled_by_default?.should.be.false
116
+ end
117
+
118
+ it "reports a single-plus delta in a file with no omits" do
119
+ messages.("REM 16 July +10 MSG hi\n").first.should.match(
120
+ /counts non-omitted days, and this file omits nothing/,
121
+ )
122
+ end
123
+
124
+ it "suggests the doubled sign" do
125
+ messages.("REM 16 July +10 MSG hi\n").first.should.match(/exactly `\+\+10`/)
126
+ end
127
+
128
+ it "reports a single tilde back" do
129
+ messages.("REM 16 July ~3 MSG hi\n").first.should.match(/exactly `~~3`/)
130
+ end
131
+
132
+ it "accepts a doubled delta" do
133
+ lint.("REM 16 July ++10 MSG hi\n").should.be.empty
134
+ end
135
+
136
+ it "accepts a doubled back" do
137
+ lint.("REM 16 July ~~3 MSG hi\n").should.be.empty
138
+ end
139
+
140
+ it "says nothing in a file that omits something" do
141
+ lint.("OMIT 1 Jan\nREM 16 July +10 MSG hi\n").should.be.empty
142
+ end
143
+
144
+ it "says nothing in a file that uses ADDOMIT" do
145
+ lint.("REM 1 Apr ADDOMIT MSG x\nREM 16 July +10 MSG hi\n").should.be.empty
146
+ end
147
+
148
+ it "says nothing in a file that includes anything" do
149
+ # The omits may be in the included file.
150
+ lint.("INCLUDE defs.rem\nREM 16 July +10 MSG hi\n").should.be.empty
151
+ end
152
+
153
+ it "does not read a delta out of the body" do
154
+ lint.("REM 16 July MSG the score was +10\n").should.be.empty
155
+ end
156
+
157
+ it "does not read arithmetic as a delta" do
158
+ lint.("SET a 3 +10\n").should.be.empty
159
+ end
160
+
161
+ it "says nothing about comments" do
162
+ lint.("# REM 16 July +10 MSG hi\n").should.be.empty
163
+ end
164
+
165
+ it "reports at info severity" do
166
+ lint.("REM 16 July +10 MSG hi\n").first.severity.should == "info"
167
+ end
168
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module RemLint
6
+ module Rules
7
+ # A variable assigned inside a `PUSH-VARS` block that the `PUSH-VARS` did
8
+ # not name.
9
+ #
10
+ # `PUSH-VARS a b c` saves exactly `a`, `b` and `c`, and `POP-VARS` restores
11
+ # exactly those. An assignment to anything else inside the block survives
12
+ # the `POP` -- which is the one thing the block was written to prevent.
13
+ #
14
+ # The bare `PUSH-VARS` with no names saves *every* variable, so a block
15
+ # written that way is never reported: it cannot leak.
16
+ #
17
+ # Nothing about this is an error, and nothing reports it. The block looks
18
+ # like a scope, reads like a scope, and quietly is not one for the variable
19
+ # somebody added later.
20
+ class PushVarsMissingName < Rule
21
+ ASSIGNMENT = /\A(?<name>[A-Za-z_]\w*)\b/
22
+
23
+ def self.default_severity
24
+ "warning"
25
+ end
26
+
27
+ def self.description
28
+ "A variable SET inside a PUSH-VARS block that the PUSH-VARS never named."
29
+ end
30
+
31
+ def check
32
+ @stack = []
33
+
34
+ document.code_commands.each do |command|
35
+ dispatch(command)
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def dispatch(command)
42
+ if command.keyword?("PUSH-VARS")
43
+ @stack.push(saved(command))
44
+ elsif command.keyword?("POP-VARS")
45
+ @stack.pop
46
+ elsif command.keyword?("SET", "UNSET")
47
+ check_assignment(command)
48
+ end
49
+ end
50
+
51
+ # The names the PUSH-VARS listed, or nil for the bare form that saves
52
+ # everything.
53
+ def saved(command)
54
+ names = command.args.strip.split(/[\s,]+/).reject(&:empty?)
55
+
56
+ if names.empty?
57
+ nil
58
+ else
59
+ names.map(&:downcase)
60
+ end
61
+ end
62
+
63
+ def check_assignment(command)
64
+ frame = @stack.last
65
+ name = assigned_name(command)
66
+
67
+ if !@stack.empty? && frame && name && !frame.include?(name.downcase)
68
+ offend(command.line, message(name, frame), column: command.keyword_column)
69
+ end
70
+ end
71
+
72
+ # `SET $SysVar` is a different namespace and `POP-VARS` does not carry
73
+ # it, so only ordinary variables are checked.
74
+ def assigned_name(command)
75
+ match = command.args.match(ASSIGNMENT)
76
+
77
+ match && match[:name]
78
+ end
79
+
80
+ def message(name, saved)
81
+ "`#{name}` is not one of the variables this `PUSH-VARS` saved " \
82
+ "(#{saved.join(', ')}), so the assignment survives the `POP-VARS`"
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ __END__
89
+
90
+ require_relative "../document"
91
+
92
+ describe "RemLint::Rules::PushVarsMissingName" do
93
+ lint = proc do |text|
94
+ source = RemLint::Source.new(path: "t.rem", text: text)
95
+
96
+ RemLint::Rules::PushVarsMissingName.new.run(RemLint::Document.new(source))
97
+ end
98
+
99
+ messages = proc { |text| lint.(text).map(&:message) }
100
+
101
+ it "reports an assignment the PUSH-VARS did not name" do
102
+ messages.("PUSH-VARS a\nSET b 1\nPOP-VARS\n").first.should ==
103
+ "`b` is not one of the variables this `PUSH-VARS` saved (a), so the assignment " \
104
+ "survives the `POP-VARS`"
105
+ end
106
+
107
+ it "accepts an assignment the PUSH-VARS named" do
108
+ lint.("PUSH-VARS a\nSET a 1\nPOP-VARS\n").should.be.empty
109
+ end
110
+
111
+ it "accepts any of several names" do
112
+ lint.("PUSH-VARS a b c\nSET b 1\nSET c 2\nPOP-VARS\n").should.be.empty
113
+ end
114
+
115
+ it "accepts names separated by commas" do
116
+ lint.("PUSH-VARS a, b\nSET b 1\nPOP-VARS\n").should.be.empty
117
+ end
118
+
119
+ it "matches names case-insensitively" do
120
+ lint.("PUSH-VARS Foo\nSET foo 1\nPOP-VARS\n").should.be.empty
121
+ end
122
+
123
+ it "accepts everything inside a bare PUSH-VARS, which saves the lot" do
124
+ lint.("PUSH-VARS\nSET anything 1\nPOP-VARS\n").should.be.empty
125
+ end
126
+
127
+ it "says nothing outside a block" do
128
+ lint.("SET b 1\nPUSH-VARS a\nPOP-VARS\nSET c 2\n").should.be.empty
129
+ end
130
+
131
+ it "reports an UNSET too" do
132
+ messages.("PUSH-VARS a\nUNSET b\nPOP-VARS\n").length.should == 1
133
+ end
134
+
135
+ it "says nothing about a system variable, which POP-VARS does not carry" do
136
+ lint.("PUSH-VARS a\nSET $FormWidth 80\nPOP-VARS\n").should.be.empty
137
+ end
138
+
139
+ it "uses the innermost block's list" do
140
+ lint.("PUSH-VARS a\nPUSH-VARS b\nSET b 1\nPOP-VARS\nPOP-VARS\n").should.be.empty
141
+ end
142
+
143
+ it "reports against the innermost block only" do
144
+ messages.("PUSH-VARS a\nPUSH-VARS b\nSET a 1\nPOP-VARS\nPOP-VARS\n").length.should == 1
145
+ end
146
+
147
+ it "points at the keyword" do
148
+ lint.("PUSH-VARS a\n SET b 1\nPOP-VARS\n").first.column.should == 4
149
+ end
150
+
151
+ it "says nothing about comments" do
152
+ lint.("PUSH-VARS a\n# SET b 1\nPOP-VARS\n").should.be.empty
153
+ end
154
+
155
+ it "reports at warning severity" do
156
+ lint.("PUSH-VARS a\nSET b 1\nPOP-VARS\n").first.severity.should == "warning"
157
+ end
158
+ end