sloplint 0.5.0 → 0.7.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.
data/exe/sloplint ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Integer compare, not string: "3.10" < "3.3" is true and would reject a future
5
+ # Ruby. Array has <=> but no <, hence the explicit compare. Not Gem::Version --
6
+ # this has to hold when RubyGems is not loaded.
7
+ if (RUBY_VERSION.split(".").map(&:to_i).first(2) <=> [3, 3]) < 0
8
+ abort "sloplint needs Ruby 3.3 or later, found #{RUBY_VERSION}.\n" \
9
+ "macOS ships Ruby 2.6 at /usr/bin/ruby. Install a current one with `brew install ruby`."
10
+ end
11
+
12
+ require_relative "../lib/sloplint/cli"
13
+ exit Sloplint::CLI.run(ARGV)
data/lib/sloplint/cli.rb CHANGED
@@ -10,7 +10,8 @@ require_relative "output"
10
10
  module Sloplint
11
11
  # Command-line shell: optparse, subcommands, exit codes. See docs/SPEC.md.
12
12
  #
13
- # Exit codes: 0 ran/no notes, 1 ran/notes found, 2 bad arguments.
13
+ # Exit codes: 0 ran/no notes, 1 ran/notes found, 2 bad arguments. Empty
14
+ # input is a 2 as well: an unread draft must not report as a clean one.
14
15
  module CLI
15
16
  module_function
16
17
 
@@ -18,7 +19,14 @@ module Sloplint
18
19
  opts = { format: "full" }
19
20
  parser = global_parser(opts, out:)
20
21
  # Split global options from the subcommand and its args.
21
- parser.order!(argv)
22
+ begin
23
+ parser.order!(argv)
24
+ rescue OptionParser::InvalidOption => e
25
+ # `check` is the default command, so its options are accepted before
26
+ # any command word: `sloplint --markdown -`. The global parser does not
27
+ # know them, so put the option back and let check's parser judge it.
28
+ argv.unshift("check", *e.args)
29
+ end
22
30
  return 0 if opts[:help_shown] || opts[:version_shown]
23
31
 
24
32
  command = argv.shift
@@ -45,15 +53,17 @@ module Sloplint
45
53
  # ── check ───────────────────────────────────────────────────────────────
46
54
  def cmd_check(argv, opts, out:, err:, stdin:)
47
55
  markdown = false
56
+ strict = false
48
57
  select = nil
49
58
  ignore = nil
50
59
  p = OptionParser.new do |o|
51
60
  o.banner = "usage: sloplint check [options] [paths...] (\"-\" or no paths = stdin)"
52
61
  o.on("-o", "--output-format FORMAT", %w[full json],
53
62
  "Output format: 'full' or 'json' (may also be given before the command).") { |v| opts[:format] = v }
54
- o.on("--markdown", "Skip fenced/inline code spans and URLs before scanning.") { markdown = true }
63
+ o.on("--markdown", "Skip fenced/inline code spans, HTML comments, and URLs before scanning.") { markdown = true }
55
64
  o.on("--select IDS", "Only run these rules (comma-separated rule ids or category names).") { |v| select = v.split(",").map(&:strip) }
56
65
  o.on("--ignore IDS", "Skip these rules (comma-separated rule ids or category names).") { |v| ignore = v.split(",").map(&:strip) }
66
+ o.on("--strict", "Run every rule, including the ones that are off by default.") { strict = true }
57
67
  end
58
68
  p.order!(argv)
59
69
 
@@ -64,24 +74,42 @@ module Sloplint
64
74
  return 2
65
75
  end
66
76
 
67
- rules = select_rules(select, ignore)
77
+ rules = select_rules(select, ignore, strict)
68
78
  paths = argv.empty? ? ["-"] : argv
69
79
  by_path = paths.reject { |x| x == "-" }.size > 1
70
80
 
71
- all_notes = []
81
+ sources = []
72
82
  paths.each do |path|
83
+ # Read as UTF-8 whatever the locale says. A sandbox with no LANG set
84
+ # leaves Ruby's default external encoding at US-ASCII, and then the
85
+ # first em dash raises "invalid byte sequence in US-ASCII" -- on prose
86
+ # that is perfectly valid UTF-8. Prose is the only input sloplint
87
+ # takes, so UTF-8 is the assumption, not the locale's guess.
73
88
  text =
74
89
  if path == "-"
75
- stdin.read
90
+ stdin.read.force_encoding(Encoding::UTF_8)
76
91
  else
77
92
  unless File.file?(path)
78
93
  err.puts("sloplint: no such file: #{path}")
79
94
  return 2
80
95
  end
81
- File.read(path)
96
+ File.read(path, encoding: Encoding::UTF_8)
82
97
  end
83
- label = path == "-" ? "-" : path
84
- all_notes.concat(Engine.scan(text, rules:, markdown:, path: label))
98
+ sources << [path == "-" ? "-" : path, text]
99
+ end
100
+
101
+ # Tested on the raw text, before --markdown blanks code and URLs: a file
102
+ # that holds only a fenced code block did arrive, and scanning it clean
103
+ # is right. Nothing arriving at all is the trap -- the same one a
104
+ # mistyped rule id sets, and it exits 2 for the same reason.
105
+ if sources.all? { |_, text| text.strip.empty? }
106
+ names = sources.map { |label, _| label == "-" ? "stdin" : label }
107
+ err.puts("sloplint: empty input: nothing to check in #{names.join(", ")}")
108
+ return 2
109
+ end
110
+
111
+ all_notes = sources.flat_map do |label, text|
112
+ Engine.scan(text, rules:, markdown:, path: label)
85
113
  end
86
114
 
87
115
  case opts[:format]
@@ -93,7 +121,10 @@ module Sloplint
93
121
  end
94
122
 
95
123
  all_notes.empty? ? 0 : 1
96
- rescue ArgumentError => e
124
+ # Invalid UTF-8 reaches this two ways: String#strip in the empty check
125
+ # raises Encoding::CompatibilityError, the engine's regexes raise
126
+ # ArgumentError. Both are the same thing to the reader.
127
+ rescue ArgumentError, Encoding::CompatibilityError => e
97
128
  err.puts("sloplint: invalid input: #{e.message}")
98
129
  2
99
130
  end
@@ -168,9 +199,11 @@ module Sloplint
168
199
 
169
200
  # --select/--ignore accept rule ids or category names. Default set excludes
170
201
  # default_on:false rules unless they are explicitly selected.
171
- def select_rules(select, ignore)
202
+ def select_rules(select, ignore, strict = false)
172
203
  rules = if select
173
204
  RULES.select { |r| select.include?(r.id) || select.include?(r.category) }
205
+ elsif strict
206
+ RULES
174
207
  else
175
208
  RULES.select(&:default_on)
176
209
  end
@@ -187,7 +220,7 @@ module Sloplint
187
220
 
188
221
  # Recommended for agents:
189
222
  cat FILE | sloplint check --markdown -o json -
190
- # exit 0 = clean, 1 = notes found, >1 = error
223
+ # exit 0 = clean, 1 = notes found, >1 = error (empty input is an error)
191
224
  # each note: {path,line,column,severity,rule,category,message,excerpt,context,rationale,suggestion}
192
225
 
193
226
  usage: sloplint [-o full|json] [command] [args]
@@ -14,7 +14,7 @@ module Sloplint
14
14
 
15
15
  module_function
16
16
 
17
- # text: the source. rules: which Rule objects to run. markdown: blank code/URLs first.
17
+ # text: the source. rules: which Rule objects to run. markdown: blank code, HTML comments and URLs first.
18
18
  # path: label carried into each Note (e.g. filename or "-" for stdin).
19
19
  def scan(text, rules: RULES, markdown: false, path: "-")
20
20
  source = text
@@ -95,14 +95,13 @@ module Sloplint
95
95
  starts
96
96
  end
97
97
 
98
- # Replace fenced code, inline code, and URLs with same-length whitespace so
99
- # line/column stay correct. Newlines are preserved.
98
+ # Replace fenced code, HTML comments, inline code, and URLs with same-length
99
+ # whitespace so line/column stay correct. Newlines are preserved. One pass
100
+ # with one alternation, so whichever construct opens first is the one that
101
+ # gets consumed: a `<!--` quoted inside backticks is inline code, and a
102
+ # backtick inside a comment is part of the comment.
100
103
  def blank_markdown(text)
101
- blank = lambda { |s| s.gsub(/[^\n]/, " ") }
102
- text
103
- .gsub(/```.*?```/m) { |s| blank.call(s) } # fenced code
104
- .gsub(/`[^`\n]*`/) { |s| blank.call(s) } # inline code
105
- .gsub(%r{https?://\S+}) { |s| blank.call(s) } # bare URLs
104
+ text.gsub(/```.*?```|<!--.*?-->|`[^`\n]*`|https?:\/\/\S+/m) { |s| s.gsub(/[^\n]/, " ") }
106
105
  end
107
106
  end
108
107
  end