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.
- checksums.yaml +4 -4
- data/README.md +156 -81
- data/THIRD_PARTY_NOTICES.md +72 -0
- data/docs/RULES.md +87 -1
- data/lib/simple_english/cli.rb +38 -43
- data/lib/simple_english/client/daemon.rb +6 -7
- data/lib/simple_english/client/language_tool.rb +5 -13
- data/lib/simple_english/daemon/engine.rb +1 -1
- data/lib/simple_english/daemon/server.rb +24 -43
- data/lib/simple_english/lint/annotated_text.rb +8 -21
- data/lib/simple_english/lint/extractor.rb +49 -2
- data/lib/simple_english/lint/finding.rb +16 -1
- data/lib/simple_english/lint/lint_plan.rb +1 -1
- data/lib/simple_english/lint/linter.rb +17 -0
- data/lib/simple_english/lint/markdown.rb +4 -7
- data/lib/simple_english/lint/utf16.rb +33 -0
- data/lib/simple_english/model_context_protocol.rb +133 -0
- data/lib/simple_english/setup/config.rb +41 -8
- data/lib/simple_english/setup/install.rb +26 -61
- data/lib/simple_english/setup/languagetool.rb +6 -213
- data/lib/simple_english/version.rb +1 -1
- data/lib/simple_english.rb +17 -28
- data/licenses/LGPL-2.1.txt +501 -0
- data/licenses/OpenJDK-LICENSE.txt +347 -0
- data/licenses/languagetool-resource-en/12dicts-readme.html +738 -0
- data/licenses/languagetool-resource-en/agid-readme.txt +340 -0
- data/licenses/languagetool-resource-en/pos-readme.txt +115 -0
- data/rules/simple-english.xml +153 -2
- metadata +23 -14
|
@@ -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)
|
|
5
|
-
# from every file)
|
|
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]
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
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 :
|
|
14
|
+
attr_reader :executable
|
|
17
15
|
|
|
18
|
-
def initialize(
|
|
19
|
-
@
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
82
|
-
"
|
|
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
|
-
#
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
data/lib/simple_english.rb
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Lint Markdown prose with the SimpleEnglish
|
|
4
|
-
# Pattern rules run on LanguageTool. Counting rules
|
|
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
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|