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,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module RemLint
6
+ module Rules
7
+ # A hand-written `TAG` in TkRemind's namespace.
8
+ #
9
+ # TkRemind finds the `REM` command it is about to edit or delete by its
10
+ # tag, and it names its own tags `TKTAG` followed by a number. A
11
+ # hand-written tag in that shape collides, and TkRemind then edits or
12
+ # deletes the wrong reminder.
13
+ #
14
+ # There is no error and no warning. The user clicks a reminder in the GUI,
15
+ # a different one changes, and nothing anywhere says why -- which makes it
16
+ # a data-loss bug with no diagnostic attached.
17
+ class TkTagNamespace < Rule
18
+ RESERVED = /\ATKTAG\d+\z/i
19
+
20
+ def self.default_severity
21
+ "warning"
22
+ end
23
+
24
+ def self.description
25
+ "A TAG in TkRemind's TKTAGn namespace, which makes the GUI edit the wrong reminder."
26
+ end
27
+
28
+ def check
29
+ document.code_commands.each do |command|
30
+ document.trigger_for(command).clauses.each do |clause|
31
+ if clause.name == "TAG"
32
+ check_tag(command, clause)
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def check_tag(command, clause)
41
+ value = tag_value(command, clause)
42
+
43
+ if value&.match?(RESERVED)
44
+ offend_at(
45
+ command.logical_line,
46
+ clause.offset,
47
+ "`#{value}` is in TkRemind's own `TKTAGn` namespace; it finds the command " \
48
+ "to edit by its tag, so a collision makes the GUI change a different " \
49
+ "reminder with no diagnostic",
50
+ )
51
+ end
52
+ end
53
+
54
+ def tag_value(command, clause)
55
+ token = document.tokens_for(command.logical_line)[clause.argument_index]
56
+
57
+ case token&.type
58
+ when :string then token.value[1..-2]
59
+ when :name then token.value
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+
66
+ __END__
67
+
68
+ require_relative "../document"
69
+
70
+ describe "RemLint::Rules::TkTagNamespace" do
71
+ lint = proc do |text|
72
+ source = RemLint::Source.new(path: "t.rem", text: text)
73
+
74
+ RemLint::Rules::TkTagNamespace.new.run(RemLint::Document.new(source))
75
+ end
76
+
77
+ it "reports a quoted tag in the reserved namespace" do
78
+ lint.(%(REM 1 Jan TAG "TKTAG1" MSG hi\n)).first.message.should.match(
79
+ /is in TkRemind's own `TKTAGn` namespace/,
80
+ )
81
+ end
82
+
83
+ it "reports an unquoted one" do
84
+ lint.("REM 1 Jan TAG TKTAG42 MSG hi\n").length.should == 1
85
+ end
86
+
87
+ it "matches the prefix case-insensitively" do
88
+ lint.("REM 1 Jan TAG tktag7 MSG hi\n").length.should == 1
89
+ end
90
+
91
+ it "accepts an ordinary tag" do
92
+ lint.(%(REM 1 Jan TAG "birthday" MSG hi\n)).should.be.empty
93
+ end
94
+
95
+ it "accepts a tag that merely starts with the prefix" do
96
+ # TkRemind's are the prefix plus digits and nothing else.
97
+ lint.(%(REM 1 Jan TAG "TKTAGGED" MSG hi\n)).should.be.empty
98
+ end
99
+
100
+ it "accepts a tag with no digits" do
101
+ lint.(%(REM 1 Jan TAG "TKTAG" MSG hi\n)).should.be.empty
102
+ end
103
+
104
+ it "says nothing about a reminder with no TAG" do
105
+ lint.("REM 1 Jan MSG hi\n").should.be.empty
106
+ end
107
+
108
+ it "explains why it matters" do
109
+ lint.("REM 1 Jan TAG TKTAG1 MSG hi\n").first.message.should.match(
110
+ /change a different reminder with no diagnostic/,
111
+ )
112
+ end
113
+
114
+ it "says nothing about comments" do
115
+ lint.("# REM 1 Jan TAG TKTAG1 MSG hi\n").should.be.empty
116
+ end
117
+
118
+ it "reports at warning severity" do
119
+ lint.("REM 1 Jan TAG TKTAG1 MSG hi\n").first.severity.should == "warning"
120
+ end
121
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module RemLint
6
+ module Rules
7
+ # A `TODO` with no `COMPLETE-THROUGH`.
8
+ #
9
+ # `COMPLETE-THROUGH` tells Remind the date up to which the task has been
10
+ # done, and it is the starting point of a TODO's entire trigger
11
+ # calculation. Without it the algorithm starts at Remind's epoch,
12
+ # 1990-01-01, so the task is decades overdue.
13
+ #
14
+ # On its own that is harmless -- the TODO simply fires. It turns into a
15
+ # defect the moment `MAX-OVERDUE` is added, because `MAX-OVERDUE` then
16
+ # suppresses a task that is thirty-odd years past due, and the reminder
17
+ # produces *nothing at all*:
18
+ #
19
+ # REM TODO Mon MAX-OVERDUE 5 MSG x -> No reminders.
20
+ # REM TODO Mon COMPLETE-THROUGH 2026-01-01 MAX-OVERDUE 5 MSG x -> fires
21
+ #
22
+ # So this reports the pair, not the missing clause alone. The book
23
+ # described the failure as a reminder that arrives screaming; it is in fact
24
+ # a reminder that never arrives, which is harder to notice and was worth
25
+ # checking against the binary rather than taking on trust. Remind's own
26
+ # `tests/` has twenty-one TODOs with no `COMPLETE-THROUGH` and they are all
27
+ # fine, because none of them sets `MAX-OVERDUE`.
28
+ class TodoCompleteThrough < Rule
29
+ def self.default_severity
30
+ "warning"
31
+ end
32
+
33
+ def self.description
34
+ "A TODO with MAX-OVERDUE and no COMPLETE-THROUGH, which suppresses it entirely."
35
+ end
36
+
37
+ def check
38
+ document.code_commands.each do |command|
39
+ trigger = document.trigger_for(command)
40
+
41
+ if trigger.include?("TODO") && trigger.include?("MAX-OVERDUE") &&
42
+ !trigger.include?("COMPLETE-THROUGH")
43
+ report(command, trigger)
44
+ end
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def report(command, trigger)
51
+ offend_at(
52
+ command.logical_line,
53
+ trigger.find("TODO").offset,
54
+ "this `TODO` has no `COMPLETE-THROUGH`, so its calculation starts at " \
55
+ "Remind's epoch and the task is decades overdue -- and the `MAX-OVERDUE` " \
56
+ "on the same command then suppresses it, so the reminder never appears",
57
+ )
58
+ end
59
+ end
60
+ end
61
+ end
62
+
63
+ __END__
64
+
65
+ require_relative "../document"
66
+
67
+ describe "RemLint::Rules::TodoCompleteThrough" do
68
+ lint = proc do |text|
69
+ source = RemLint::Source.new(path: "t.rem", text: text)
70
+
71
+ RemLint::Rules::TodoCompleteThrough.new.run(RemLint::Document.new(source))
72
+ end
73
+
74
+ it "reports a TODO with MAX-OVERDUE and no COMPLETE-THROUGH" do
75
+ lint.("REM Mon TODO MAX-OVERDUE 5 MSG File\n").first.message.should.match(
76
+ /suppresses it, so the reminder never appears/,
77
+ )
78
+ end
79
+
80
+ it "accepts one that has COMPLETE-THROUGH" do
81
+ text = "REM Mon TODO COMPLETE-THROUGH 2026-01-01 MAX-OVERDUE 5 MSG File\n"
82
+
83
+ lint.(text).should.be.empty
84
+ end
85
+
86
+ it "accepts the clauses in any order" do
87
+ text = "REM Mon MAX-OVERDUE 5 COMPLETE-THROUGH 2026-01-01 TODO MSG File\n"
88
+
89
+ lint.(text).should.be.empty
90
+ end
91
+
92
+ it "accepts a TODO with no MAX-OVERDUE, which simply fires" do
93
+ # Remind's own tests/ has twenty-one of these and they all work.
94
+ lint.("REM 1 TODO MSG File the accounts\n").should.be.empty
95
+ end
96
+
97
+ it "says nothing about MAX-OVERDUE without a TODO" do
98
+ lint.("REM 1 MAX-OVERDUE 5 MSG File\n").should.be.empty
99
+ end
100
+
101
+ it "says nothing about a reminder that is not a TODO" do
102
+ lint.("REM 1 MSG File the accounts\n").should.be.empty
103
+ end
104
+
105
+ it "points at the TODO clause" do
106
+ text = "REM Mon TODO MAX-OVERDUE 5 MSG File\n"
107
+
108
+ lint.(text).first.column.should == text.index("TODO") + 1
109
+ end
110
+
111
+ it "does not read the word todo out of a body" do
112
+ lint.("REM 1 MSG add this to the todo list\n").should.be.empty
113
+ end
114
+
115
+ it "says nothing about comments" do
116
+ lint.("# REM Mon TODO MAX-OVERDUE 5 MSG File\n").should.be.empty
117
+ end
118
+
119
+ it "reports at warning severity" do
120
+ lint.("REM Mon TODO MAX-OVERDUE 5 MSG File\n").first.severity.should == "warning"
121
+ end
122
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module RemLint
6
+ module Rules
7
+ # Whitespace at the end of a line.
8
+ #
9
+ # Not bikeshedding in Remind. The shipped `examples/remind.vim` highlights
10
+ # it as an *error*, with the note "This will match trailing whitespaces
11
+ # that seem to break rem2ps". A linter that stayed quiet about it would
12
+ # disagree with the editor Remind ships.
13
+ #
14
+ # The pattern is vim's -- `\S\s\+$`, a non-space then whitespace -- so a
15
+ # line of nothing but spaces is left alone. Emptying such a line is a
16
+ # cosmetic change and flagging every one of them in a file that has a few
17
+ # is how a linter teaches people to ignore it.
18
+ #
19
+ # A trailing run that follows a backslash belongs to DanglingContinuation,
20
+ # which has something more useful to say about it, so it is skipped here
21
+ # rather than reported twice.
22
+ class TrailingWhitespace < Rule
23
+ OFFENDING = /\S[ \t]+\z/
24
+ AFTER_CONTINUATION = /\\[ \t]+\z/
25
+
26
+ MESSAGE = "Trailing whitespace (breaks rem2ps output)"
27
+
28
+ def self.default_severity
29
+ "error"
30
+ end
31
+
32
+ def self.description
33
+ "Whitespace at the end of a line, which breaks rem2ps output."
34
+ end
35
+
36
+ def check
37
+ document.each_raw_line do |raw, line|
38
+ body = raw.chomp
39
+
40
+ if offending?(body)
41
+ offend(line, MESSAGE, column: body.rstrip.length + 1)
42
+ end
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def offending?(body)
49
+ body.match?(OFFENDING) && !body.match?(AFTER_CONTINUATION)
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ __END__
56
+
57
+ require_relative "../document"
58
+
59
+ describe "RemLint::Rules::TrailingWhitespace" do
60
+ lint = proc do |text, config = {}|
61
+ source = RemLint::Source.new(path: "t.rem", text: text)
62
+
63
+ RemLint::Rules::TrailingWhitespace.new(config).run(RemLint::Document.new(source))
64
+ end
65
+
66
+ it "flags a line that ends in a space" do
67
+ offenses = lint.("MSG hello \n")
68
+
69
+ offenses.length.should == 1
70
+ offenses.first.line.should == 1
71
+ offenses.first.message.should.match(/rem2ps/)
72
+ end
73
+
74
+ it "points at the first character of the trailing run" do
75
+ lint.("MSG hello \n").first.column.should == 10
76
+ end
77
+
78
+ it "flags a trailing tab" do
79
+ lint.("MSG hello\t\n").length.should == 1
80
+ end
81
+
82
+ it "reports at error severity, as remind.vim does" do
83
+ lint.("MSG hello \n").first.severity.should == "error"
84
+ end
85
+
86
+ it "says nothing about a clean file" do
87
+ lint.("MSG hello\nMSG again\n").should.be.empty
88
+ end
89
+
90
+ it "says nothing about a line of nothing but whitespace" do
91
+ # remind.vim's pattern needs a non-space before the run, and so does this.
92
+ lint.("MSG hi\n \nMSG bye\n").should.be.empty
93
+ end
94
+
95
+ it "leaves a dangling continuation to the rule that explains it" do
96
+ lint.("SET x 1 + \\ \n").should.be.empty
97
+ end
98
+
99
+ it "still flags a line whose backslash is not at the end" do
100
+ lint.("MSG a \\ b \n").length.should == 1
101
+ end
102
+
103
+ it "flags every offending line in a file" do
104
+ lint.("MSG a \nMSG b\nMSG c\t\n").map(&:line).should == [1, 3]
105
+ end
106
+
107
+ it "reports lines of a heredoc at their position in the enclosing file" do
108
+ source = RemLint::Source.new(path: "astro", text: "MSG a \n", line_offset: 40)
109
+
110
+ RemLint::Rules::TrailingWhitespace.new.run(RemLint::Document.new(source)).first.line.should == 41
111
+ end
112
+
113
+ it "takes its severity from configuration" do
114
+ lint.("MSG a \n", "Severity" => "warning").first.severity.should == "warning"
115
+ end
116
+ end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rule"
4
+
5
+ module RemLint
6
+ module Rules
7
+ # `TRANSLATE` commands Remind will not accept, or will reject silently.
8
+ #
9
+ # `TRANSLATE` has five forms and nothing else parses:
10
+ #
11
+ # TRANSLATE "original" "translated"
12
+ # TRANSLATE "original" (removes one)
13
+ # TRANSLATE CLEAR
14
+ # TRANSLATE DUMP
15
+ # TRANSLATE LOAD "file"
16
+ #
17
+ # The bare-word forms are the ones that go wrong, because they are one
18
+ # forgotten pair of quotes away from being a translation *of* the literal
19
+ # string "CLEAR".
20
+ #
21
+ # The other half is the one that matters to a translator. A translated
22
+ # string has to carry the same `%` escapes as the original, in the same
23
+ # order -- Remind compares them and refuses the translation outright if
24
+ # they differ. The message then stays in English, which looks like a
25
+ # missing translation rather than a rejected one.
26
+ class TranslateCommand < Rule
27
+ BARE_WORDS = %w[CLEAR DUMP].freeze
28
+
29
+ LOAD = "LOAD"
30
+
31
+ # `%a`, `%*b`, `%<Header>` and the rest -- anything the substitution
32
+ # filter would replace, which both sides have to agree on.
33
+ ESCAPE = /%\*?(?:<[^>]*>|\{[^}]*\}|\([^)]*\)|.)/
34
+
35
+ STRING = /\A\s*"(?:[^"\\]|\\.)*"/
36
+
37
+ def self.default_severity
38
+ "warning"
39
+ end
40
+
41
+ def self.description
42
+ "A TRANSLATE that is not one of Remind's five forms, or whose escapes do not match."
43
+ end
44
+
45
+ def check
46
+ document.code_commands.each do |command|
47
+ if command.keyword?("TRANSLATE")
48
+ check_command(command)
49
+ end
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def check_command(command)
56
+ args = command.args.strip
57
+ word = args[/\A\S+/].to_s.upcase
58
+
59
+ if BARE_WORDS.include?(word) || word == LOAD
60
+ nil
61
+ else
62
+ check_strings(command, args)
63
+ end
64
+ end
65
+
66
+ def check_strings(command, args)
67
+ strings = quoted(args)
68
+
69
+ if strings.empty?
70
+ offend(command.line, form_message(args), column: command.keyword_column)
71
+ elsif strings.length >= 2
72
+ compare(command, strings[0], strings[1])
73
+ end
74
+ end
75
+
76
+ # The one or two quoted strings the command carries.
77
+ def quoted(args)
78
+ rest = args.dup
79
+ found = []
80
+
81
+ while (match = rest.match(STRING))
82
+ found << match[0].strip
83
+ rest = match.post_match
84
+ end
85
+
86
+ found
87
+ end
88
+
89
+ def compare(command, original, translated)
90
+ from = original.scan(ESCAPE)
91
+ to = translated.scan(ESCAPE)
92
+
93
+ if from != to
94
+ offend(command.line, escape_message(from, to), column: command.keyword_column)
95
+ end
96
+ end
97
+
98
+ def form_message(args)
99
+ "`TRANSLATE #{args.split(/\s+/).first}` is none of Remind's five forms; it takes " \
100
+ "a quoted string, two quoted strings, `CLEAR`, `DUMP`, or `LOAD \"file\"`"
101
+ end
102
+
103
+ def escape_message(from, to)
104
+ "the translation carries #{describe(to)} where the original has #{describe(from)}; " \
105
+ "Remind compares them and rejects a translation whose escapes differ, leaving " \
106
+ "the message in English"
107
+ end
108
+
109
+ def describe(escapes)
110
+ if escapes.empty?
111
+ "none"
112
+ else
113
+ escapes.map { |escape| "`#{escape}`" }.join(" ")
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+
120
+ __END__
121
+
122
+ require_relative "../document"
123
+
124
+ describe "RemLint::Rules::TranslateCommand" do
125
+ lint = proc do |text|
126
+ source = RemLint::Source.new(path: "t.rem", text: text)
127
+
128
+ RemLint::Rules::TranslateCommand.new.run(RemLint::Document.new(source))
129
+ end
130
+
131
+ messages = proc { |text| lint.(text).map(&:message) }
132
+
133
+ describe "the five forms" do
134
+ it "accepts two quoted strings" do
135
+ lint.(%(TRANSLATE "Monday" "Montag"\n)).should.be.empty
136
+ end
137
+
138
+ it "accepts one quoted string" do
139
+ lint.(%(TRANSLATE "Monday"\n)).should.be.empty
140
+ end
141
+
142
+ it "accepts CLEAR and DUMP" do
143
+ lint.("TRANSLATE CLEAR\n").should.be.empty
144
+ lint.("TRANSLATE DUMP\n").should.be.empty
145
+ end
146
+
147
+ it "accepts LOAD with a filename" do
148
+ lint.(%(TRANSLATE LOAD "de.rem"\n)).should.be.empty
149
+ end
150
+
151
+ it "accepts the bare words in lower case" do
152
+ lint.("translate clear\n").should.be.empty
153
+ end
154
+
155
+ it "reports anything else" do
156
+ messages.("TRANSLATE Monday Montag\n").first.should.match(
157
+ /is none of Remind's five forms/,
158
+ )
159
+ end
160
+ end
161
+
162
+ describe "escapes that do not match" do
163
+ it "reports a missing escape" do
164
+ messages.(%(TRANSLATE "in %b" "in"\n)).first.should.match(
165
+ /carries none where the original has `%b`/,
166
+ )
167
+ end
168
+
169
+ it "reports an added escape" do
170
+ messages.(%(TRANSLATE "today" "heute %b"\n)).first.should.match(/carries `%b`/)
171
+ end
172
+
173
+ it "reports escapes in a different order" do
174
+ messages.(%(TRANSLATE "%a and %b" "%b und %a"\n)).first.should.match(
175
+ /carries `%b` `%a` where the original has `%a` `%b`/,
176
+ )
177
+ end
178
+
179
+ it "accepts matching escapes" do
180
+ lint.(%(TRANSLATE "%a and %b" "%a und %b"\n)).should.be.empty
181
+ end
182
+
183
+ it "accepts two strings with no escapes at all" do
184
+ lint.(%(TRANSLATE "Monday" "Montag"\n)).should.be.empty
185
+ end
186
+
187
+ it "explains what Remind does about it" do
188
+ messages.(%(TRANSLATE "in %b" "in"\n)).first.should.match(/leaving the message in English/)
189
+ end
190
+ end
191
+
192
+ it "says nothing about comments" do
193
+ lint.("# TRANSLATE Monday Montag\n").should.be.empty
194
+ end
195
+
196
+ it "points at the keyword" do
197
+ lint.(" TRANSLATE Monday Montag\n").first.column.should == 4
198
+ end
199
+
200
+ it "reports at warning severity" do
201
+ lint.("TRANSLATE Monday Montag\n").first.severity.should == "warning"
202
+ end
203
+ end