simple_english 0.4.2 → 0.13.1

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.
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # .simple-english.yml: ignore (path globs, matched against the paths
4
- # given on the command line) and disabled-rules (rule IDs dropped
5
- # from every file). Loaded from the CWD.
4
+ # given on the command line), disabled-rules (rule IDs dropped
5
+ # from every file), and rule-ignores (a rule dropped only on the
6
+ # listed path globs, fired everywhere else). Loaded from the CWD.
6
7
 
7
8
  module SimpleEnglish
8
9
  module Config
9
10
  ConfigError = Class.new(StandardError)
10
- DEFAULT = {ignore: [], disabled_rules: []}.freeze
11
+ DEFAULT = {ignore: [], disabled_rules: [], rule_ignores: {}}.freeze
11
12
 
12
13
  module_function
13
14
 
@@ -17,13 +18,39 @@ module SimpleEnglish
17
18
  require "yaml"
18
19
  data = YAML.safe_load_file(file) || {}
19
20
  {ignore: Array(data["ignore"]),
20
- disabled_rules: Array(data["disabled-rules"])}
21
+ disabled_rules: Array(data["disabled-rules"]),
22
+ rule_ignores: load_rule_ignores(data["rule-ignores"])}
21
23
  rescue Psych::SyntaxError => e
22
24
  raise ConfigError, ".simple-english.yml: #{e.message}"
23
25
  end
24
26
 
27
+ # Loose like the other keys: a rule ID with no entry means the
28
+ # rule fires everywhere, so a wrong ID surfaces its findings
29
+ # instead of hiding them.
30
+ def load_rule_ignores(data)
31
+ return {} unless data.is_a?(Hash)
32
+ data.to_h { |rule, globs| [rule, Array(globs)] }
33
+ end
34
+
35
+ # Hooks pass absolute paths while ignore globs are written
36
+ # relative to the repo root (the CWD). Match both forms.
37
+ # realpath because CWD may be a symlink-resolved path while the
38
+ # caller's string still carries the symlink (TMPDIR on macOS).
25
39
  def ignore?(config, path)
26
- config[:ignore].any? { |pattern| matches?(pattern, path) }
40
+ path_matches?(config[:ignore], path)
41
+ end
42
+
43
+ def path_matches?(globs, path)
44
+ candidates = [path, relative_candidate(path)].compact
45
+ globs.any? do |pattern|
46
+ candidates.any? { |candidate| matches?(pattern, candidate) }
47
+ end
48
+ end
49
+
50
+ def relative_candidate(path)
51
+ pwd = Dir.pwd + "/"
52
+ resolved = File.exist?(path) ? File.realpath(path) : File.expand_path(path)
53
+ resolved.start_with?(pwd) ? resolved.delete_prefix(pwd) : nil
27
54
  end
28
55
 
29
56
  # File.fnmatch has no globstar: `**` never crosses directories.
@@ -43,10 +70,16 @@ module SimpleEnglish
43
70
  end.join("/") + "\\z")
44
71
  end
45
72
 
46
- def filter(config, path, findings)
47
- return [] if ignore?(config, path)
73
+ # rule-ignores needs the finding's file, which findings do not
74
+ # carry, so the caller passes path in. A nil path (stdin, MCP
75
+ # text) has no path to match, so every rule fires.
76
+ def filter(config, findings, path: nil)
48
77
  disabled = config[:disabled_rules]
49
- findings.reject { |finding| disabled.include?(finding.rule) }
78
+ rule_ignores = config[:rule_ignores] || {}
79
+ findings.reject do |finding|
80
+ disabled.include?(finding.rule) ||
81
+ (path && path_matches?(rule_ignores[finding.rule] || [], path))
82
+ end
50
83
  end
51
84
  end
52
85
  end
@@ -1,10 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # The resolved LanguageTool installation: where the jars live and which
4
- # java runs them. Build it once from the environment at the process
5
- # edge with Install.from_env. Everything downstream takes the value,
6
- # never the environment. A nil java means "not found": ask with java?
7
- # or fail with java! and get the fix in the message.
3
+ # Resolves the bundled native LanguageTool server once, at the process edge.
4
+ # Tests and source builds can select an explicit executable through the
5
+ # environment. Released platform gems provide the default under libexec.
8
6
 
9
7
  require_relative "languagetool"
10
8
 
@@ -13,73 +11,40 @@ module SimpleEnglish
13
11
  class SetupError < StandardError
14
12
  end
15
13
 
16
- attr_reader :cache_dir, :java, :java_source
14
+ attr_reader :executable
17
15
 
18
- def initialize(cache_dir:, java: nil, java_source: nil)
19
- @cache_dir = cache_dir
20
- @java = java
21
- @java_source = java_source
16
+ def initialize(executable:)
17
+ @executable = File.expand_path(executable)
22
18
  end
23
19
 
24
- # The one place the environment is read. SE_JAVA wins outright,
25
- # because the user asserted it and nobody re-probes an override.
26
- # Otherwise the code probes the PATH candidate by running it,
27
- # because a file can exist and still be the macOS stub that
28
- # reports no runtime. The last resort is the Homebrew location,
29
- # which sits outside PATH. java_source records which of the three
30
- # won, so `se setup` can say so.
31
20
  def self.from_env(env = ENV)
32
- cache = File.expand_path(env.fetch(LanguageTool::CACHE_DIR_ENV) do
33
- File.join(Dir.home, ".cache", "se")
34
- end)
35
- java, source =
36
- if (explicit = env[LanguageTool::JAVA_ENV])
37
- [explicit, :env]
38
- elsif probe?("java", env)
39
- ["java", :path]
40
- elsif File.executable?(LanguageTool::HOMEBREW_JAVA)
41
- [LanguageTool::HOMEBREW_JAVA, :homebrew]
42
- end
43
- new(cache_dir: cache, java: java, java_source: source)
21
+ executable = env.fetch(LanguageTool::EXECUTABLE_ENV,
22
+ LanguageTool::BUNDLED_EXECUTABLE)
23
+ new(executable: executable)
44
24
  end
45
25
 
46
- # Runs the candidate under the given PATH, isolated from this
47
- # process's own PATH so tests can probe a fake environment.
48
- def self.probe?(candidate, env)
49
- system({"PATH" => env.fetch("PATH", "")}, candidate, "-version",
50
- out: File::NULL, err: File::NULL)
26
+ def executable?
27
+ File.file?(executable) && File.executable?(executable)
51
28
  end
52
- private_class_method :probe?
53
29
 
54
- def java?
55
- !java.nil?
56
- end
57
-
58
- def java!
59
- return java if java?
60
- raise SetupError, java_message
61
- end
62
-
63
- def java_message
64
- "java not found. Install a JRE (on macOS: brew install openjdk), " \
65
- "or set SE_JAVA to your java binary."
66
- end
67
-
68
- def lt_dir
69
- File.join(cache_dir, "LanguageTool-#{LanguageTool::LT_VERSION}")
70
- end
71
-
72
- def commandline_jar
73
- File.join(lt_dir, "languagetool-commandline.jar")
74
- end
75
-
76
- def server_jar
77
- File.join(lt_dir, "languagetool-server.jar")
30
+ def executable!
31
+ return executable if executable?
32
+ raise SetupError, setup_error
78
33
  end
79
34
 
80
35
  def setup_error
81
- "LanguageTool #{LanguageTool::LT_VERSION} not found at #{server_jar}. " \
82
- "This gem pins that version. Run `se setup`."
36
+ if executable != LanguageTool::BUNDLED_EXECUTABLE
37
+ "#{LanguageTool::EXECUTABLE_ENV} points at a missing or " \
38
+ "non-executable file: #{executable}"
39
+ elsif File.directory?(File.dirname(executable))
40
+ "the lint engine is missing or not executable at " \
41
+ "#{executable}; reinstall the gem"
42
+ else
43
+ "this copy ships no lint engine: a source checkout and the " \
44
+ "plain gem both do. Install a platform build of the gem, or " \
45
+ "point #{LanguageTool::EXECUTABLE_ENV} at a languagetool-server " \
46
+ "binary."
47
+ end
83
48
  end
84
49
  end
85
50
  end
@@ -1,227 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # The pinned LanguageTool distro: its version, its rules file, and the
4
- # downloader. Everything that resolves "where it is on this machine and
5
- # which java runs it" lives in Install. The engine runs as the daemon's
6
- # inner HTTP server (lib/simple_english/daemon/server.rb).
3
+ # Pinned LanguageTool facts shared by the native build and Ruby runtime.
7
4
 
8
5
  module SimpleEnglish
9
6
  module LanguageTool
10
7
  RULES_FILE = File.expand_path("../../../rules/simple-english.xml", __dir__)
11
- # The only place a LanguageTool version number appears. `se
12
- # setup` downloads this version, and every jar path derives from it. No
13
- # env var can point at another one.
14
8
  LT_VERSION = "6.6"
15
- CACHE_DIR_ENV = "SE_CACHE_DIR"
16
- JAVA_ENV = "SE_JAVA"
17
- # macOS ships a /usr/bin/java stub that reports no runtime. Homebrew
18
- # installs the real one here, outside PATH.
19
- HOMEBREW_JAVA = "/opt/homebrew/opt/openjdk/bin/java"
20
- TIMEOUT_SECONDS = 300
9
+ EXECUTABLE_ENV = "SE_LANGUAGETOOL_EXECUTABLE"
10
+ BUNDLED_EXECUTABLE = File.expand_path(
11
+ "../../../libexec/simple_english/languagetool-server", __dir__
12
+ )
13
+ TIMEOUT_SECONDS = 30
21
14
 
22
15
  module_function
23
16
 
24
- # Download and unpack the pinned LanguageTool into DIR. Idempotent:
25
- # returns {"dir" => install dir, "downloaded" => bool} so `se setup`
26
- # can say what it found and what it fetched, or nil after warning
27
- # why. Pure Ruby throughout: the download uses Net::HTTP and the
28
- # unpack parses the zip with stdlib Zlib, so setup needs no curl,
29
- # unzip, or unpack gem on the machine.
30
- def install(dir)
31
- require "fileutils"
32
- dest = File.join(dir, "LanguageTool-#{LT_VERSION}")
33
- if File.exist?(File.join(dest, "languagetool-commandline.jar"))
34
- remove_stale_versions(dir, dest)
35
- return {"dir" => dest, "downloaded" => false}
36
- end
37
- zip = File.join(dir, "LanguageTool-#{LT_VERSION}.zip")
38
- FileUtils.mkdir_p(dir)
39
- return nil unless download(download_url, zip)
40
- return nil unless extract(zip, dir)
41
- File.delete(zip)
42
- remove_stale_versions(dir, dest)
43
- {"dir" => dest, "downloaded" => true}
44
- end
45
-
46
- def download_url
47
- "https://languagetool.org/download/LanguageTool-#{LT_VERSION}.zip"
48
- end
49
-
50
- # Streams URL to TO and follows redirects. Warns and returns false
51
- # on any transport or HTTP failure, so callers never see a stack
52
- # trace.
53
- def download(url, to, redirects: 3)
54
- require "net/http"
55
- uri = URI(url)
56
- Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
57
- open_timeout: 10, read_timeout: 60) do |http|
58
- http.request(Net::HTTP::Get.new(uri)) do |response|
59
- case response
60
- when Net::HTTPRedirection
61
- return false if redirects.zero?
62
- return download(response["location"], to, redirects: redirects - 1)
63
- when Net::HTTPSuccess
64
- File.binwrite(to, "")
65
- File.open(to, "wb") do |file|
66
- response.read_body { |chunk| file.write(chunk) }
67
- end
68
- return true
69
- else
70
- warn "error: download failed: HTTP #{response.code} from #{url}"
71
- return false
72
- end
73
- end
74
- end
75
- rescue SystemCallError, SocketError, Timeout::Error,
76
- OpenSSL::SSL::SSLError, Net::ProtocolError => e
77
- warn "error: download failed: #{e.class}: #{e.message}"
78
- false
79
- end
80
-
81
- # Unpacks ZIP into DIR on stdlib Zlib: the End of Central Directory
82
- # record locates the central directory, each entry names a local
83
- # header, and each stream inflates raw. The input is the one pinned
84
- # LanguageTool distro, not arbitrary archives, so zip64 (4 GB+ or
85
- # 65,535+ entries) is refused, not parsed. Warns and returns false
86
- # on any failure.
87
- def extract(zip, dir)
88
- require "fileutils"
89
- require "zlib"
90
- bytes = File.binread(zip)
91
- tail = eocd(bytes)
92
- raise Zlib::DataError, "not a zip archive" if tail.nil?
93
- count, cd_offset = tail
94
- pos = cd_offset
95
- count.times do
96
- name, method, csize, crc, local, pos = cd_entry(bytes, pos)
97
- raise Zlib::DataError, "corrupt central directory" if name.nil?
98
- next if name.end_with?("/")
99
- target = safe_entry_target(dir, name)
100
- if target.nil?
101
- warn "error: zip entry escapes the install dir: #{name}"
102
- return false
103
- end
104
- data_at = local_data_offset(bytes, local)
105
- raise Zlib::DataError, "corrupt local header" if data_at.nil?
106
- data = inflate_entry(bytes.byteslice(data_at, csize), method)
107
- raise Zlib::DataError, "CRC mismatch: #{name}" unless Zlib.crc32(data) == crc
108
- FileUtils.mkdir_p(File.dirname(target))
109
- File.binwrite(target, data)
110
- end
111
- true
112
- rescue Zlib::Error, SystemCallError => e
113
- warn "error: unpack failed: #{e.class}: #{e.message}"
114
- false
115
- end
116
-
117
- # The End of Central Directory record: the entry count and the
118
- # central directory offset, or nil. The record ends the archive,
119
- # possibly behind a comment of at most 65,535 bytes, so the scan
120
- # starts at the tail. Zip64 sentinels are refused: the pinned
121
- # distro is far under the limits.
122
- def eocd(bytes)
123
- limit = [bytes.bytesize, 65_557].min
124
- window = bytes.byteslice(bytes.bytesize - limit, limit)
125
- at = window.rindex("PK\x05\x06")
126
- return nil if at.nil?
127
- rec = window.byteslice(at, 22)
128
- return nil if rec.bytesize < 22
129
- count = rec.byteslice(10, 2).unpack1("v")
130
- cd = rec.byteslice(16, 4).unpack1("V")
131
- return nil if count == 0xFFFF || cd == 0xFFFFFFFF
132
- [count, cd]
133
- end
134
-
135
- # One central-directory record at POS: the entry name,
136
- # compression method, compressed size, CRC-32, local header
137
- # offset, and the offset of the next record. nil when the record
138
- # is truncated or unsigned.
139
- def cd_entry(bytes, pos)
140
- head = bytes.byteslice(pos, 46)
141
- return nil unless head.byteslice(0, 4) == "PK\x01\x02" && head.bytesize == 46
142
- name_len = head.byteslice(28, 2).unpack1("v")
143
- name = bytes.byteslice(pos + 46, name_len)
144
- [name,
145
- head.byteslice(10, 2).unpack1("v"),
146
- head.byteslice(20, 4).unpack1("V"),
147
- head.byteslice(16, 4).unpack1("V"),
148
- head.byteslice(42, 4).unpack1("V"),
149
- pos + 46 + name_len + head.byteslice(30, 2).unpack1("v") +
150
- head.byteslice(32, 2).unpack1("v")]
151
- end
152
-
153
- # Where an entry's data sits: the local header at OFFSET names
154
- # its own name and extra lengths, and the data follows them. nil
155
- # when the header is truncated or unsigned.
156
- def local_data_offset(bytes, offset)
157
- head = bytes.byteslice(offset, 30)
158
- return nil unless head.byteslice(0, 4) == "PK\x03\x04" && head.bytesize == 30
159
- offset + 30 + head.byteslice(26, 2).unpack1("v") +
160
- head.byteslice(28, 2).unpack1("v")
161
- end
162
-
163
- # One entry's bytes: STORED is copied, DEFLATE is inflated raw.
164
- def inflate_entry(blob, method)
165
- return blob if method.zero?
166
- return Zlib::Inflate.new(-Zlib::MAX_WBITS).inflate(blob) if method == 8
167
- raise Zlib::DataError, "unsupported compression method #{method}"
168
- end
169
-
170
- # The path to write zip entry NAME into, or nil when NAME escapes
171
- # DIR. A download is untrusted input: this is the zip-slip
172
- # guard.
173
- def safe_entry_target(dir, name)
174
- target = File.expand_path(File.join(dir, name))
175
- base = File.expand_path(dir) + File::SEPARATOR
176
- target.start_with?(base) ? target : nil
177
- end
178
-
179
- # One gem version pins one LanguageTool version. Older dirs are
180
- # hundreds of MB each, so delete them on every setup run and keep
181
- # only the current version (and any zips: they are re-deleted or
182
- # re-extracted by their own flow).
183
- def remove_stale_versions(dir, keep)
184
- require "fileutils"
185
- Dir.glob(File.join(dir, "LanguageTool-*")).each do |entry|
186
- FileUtils.rm_rf(entry) if File.directory?(entry) && entry != keep
187
- end
188
- end
189
-
190
- # Proves the installation works end to end: java runs the
191
- # commandline jar and the custom rules load. The command exits
192
- # nonzero on a broken java, a corrupt jar, or invalid rules XML,
193
- # so setup can report a real verdict instead of "files exist".
194
- # Returns a boolean, never raises or warns: the caller owns the
195
- # message.
196
- def smoke(install)
197
- rule = rule_ids.first
198
- return false if rule.nil?
199
- require "tmpdir"
200
- Dir.mktmpdir do |dir|
201
- text = File.join(dir, "smoke.txt")
202
- File.write(text, "Check this sentence.\n")
203
- system(install.java!, "-jar", install.commandline_jar,
204
- "--language", "en", "--rulefile", RULES_FILE,
205
- "--enable", rule, "--enabledonly", text,
206
- out: File::NULL, err: File::NULL) ? true : false
207
- end
208
- end
209
-
210
- # All rule IDs in RULES_FILE, in document order. The tag scan pairs each opening rule or rulegroup
211
- # tag with the id attribute inside it, so attribute order never
212
- # matters. It reads the repo's own file, which the test suite
213
- # round-trips through LanguageTool, so malformed XML fails the
214
- # build, not the boot. The daemon's enabledRules parameter on
215
- # every lint request needs this list. A rule LT loads but the
216
- # list omits is dead, because enabledOnly is set.
217
- # examples_check asserts this list against a real XML parser,
218
- # and LT itself rejects duplicate ids at load, so no dedup is
219
- # needed here.
220
17
  def rule_ids(path = RULES_FILE)
221
- # Explicit UTF-8: the file holds non-ASCII bytes, and a bare
222
- # File.read uses the process's external encoding. Under
223
- # launchd that is US-ASCII, and the scan below dies on the
224
- # first such byte. The daemon must boot in any locale.
225
18
  File.read(path, encoding: "UTF-8").scan(/<(?:rule|rulegroup)\b[^>]*>/).filter_map do |tag|
226
19
  tag[/\bid="([^"]+)"/, 1]
227
20
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SimpleEnglish
4
- VERSION = "0.4.2"
4
+ VERSION = "0.13.1"
5
5
  end
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Lint Markdown prose with the SimpleEnglish Plain-mode rules.
4
- # Pattern rules run on LanguageTool. Counting rules run here.
3
+ # Lint Markdown prose and code comments with the SimpleEnglish
4
+ # Plain-mode rules. Pattern rules run on LanguageTool. Counting rules
5
+ # run here.
5
6
  # This file is the composition root. The pieces live in lib/simple_english/.
6
7
 
7
8
  require_relative "simple_english/version"
8
9
  require_relative "simple_english/lint/finding"
9
10
  require_relative "simple_english/lint/paragraph"
11
+ require_relative "simple_english/lint/utf16"
10
12
  require_relative "simple_english/lint/markdown"
11
13
  require_relative "simple_english/lint/counts"
12
14
  require_relative "simple_english/setup/languagetool"
@@ -15,45 +17,32 @@ require_relative "simple_english/lint/extractor"
15
17
  require_relative "simple_english/lint/lint_plan"
16
18
  require_relative "simple_english/lint/annotated_text"
17
19
  require_relative "simple_english/lint/suppressions"
20
+ require_relative "simple_english/lint/linter"
18
21
  require_relative "simple_english/setup/config"
19
22
  require_relative "simple_english/setup/fingerprint"
20
23
  require_relative "simple_english/client/language_tool"
21
24
  require_relative "simple_english/client/daemon"
22
25
  require_relative "simple_english/daemon/engine"
23
26
  require_relative "simple_english/daemon/http"
27
+ require_relative "simple_english/model_context_protocol"
24
28
  require_relative "simple_english/daemon/server"
25
29
 
26
30
  module SimpleEnglish
27
31
  module_function
28
32
 
29
- # Returns findings, or nil when the daemon is unreachable. The
30
- # caller (bin/se) owns the exit status. Diagnostics go to
31
- # stderr here.
32
- def lint_text(text)
33
- return nil unless Client.ensure_up(install: Install.from_env)
34
- Client.lint(text)
35
- end
36
-
37
- # One entry point for files. LintPlan decides what kind of lint
38
- # this is. Both tiers share it, so they cannot drift. Code files
39
- # lint comments through the daemon. nil means unreachable:
40
- # the caller decides how fatal that is.
41
- def lint_file(path, text = File.read(path))
42
- language = Extractor.language_for(path)
33
+ # The engine: text in, findings out. LintPlan decides what kind of
34
+ # lint this is. Both tiers share it, so they cannot drift. A nil
35
+ # language means Markdown prose. Code lints its comments.
36
+ # nil means unreachable: the caller decides how fatal that is.
37
+ def lint(text, language: nil)
43
38
  plan = LintPlan.call(text, language)
44
39
  return [] if plan.nil?
45
- findings =
46
- if plan == :prose
47
- lint_text(text)
48
- else
49
- return nil unless Client.ensure_up(install: Install.from_env)
50
- result = Client.lint(text, language: language)
51
- if result.nil?
52
- warn "error: se daemon did not answer. Run `#{Client.runner} serve` and read its output."
53
- end
54
- result
55
- end
56
- return nil unless findings
40
+ return nil unless Client.ensure_up(install: Install.from_env)
41
+ findings = Client.lint(text, language: language)
42
+ if findings.nil?
43
+ warn "error: se daemon did not answer. Run `#{Client.runner} serve` and read its output."
44
+ return nil
45
+ end
57
46
  Suppressions.filter(text, findings)
58
47
  end
59
48
  end