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
|
@@ -141,8 +141,8 @@ module SimpleEnglish
|
|
|
141
141
|
# Preflight everything the spawn needs: a missing prerequisite
|
|
142
142
|
# must fail fast with the real blocker named, not spawn a
|
|
143
143
|
# doomed child and wait out 90 s.
|
|
144
|
-
unless
|
|
145
|
-
warn
|
|
144
|
+
unless install.executable?
|
|
145
|
+
warn install.setup_error
|
|
146
146
|
return false
|
|
147
147
|
end
|
|
148
148
|
# One boot at a time: a concurrent lint that also found the
|
|
@@ -153,7 +153,7 @@ module SimpleEnglish
|
|
|
153
153
|
with_spawn_lock do
|
|
154
154
|
daemon = info
|
|
155
155
|
unless daemon
|
|
156
|
-
warn "se: daemon not running; starting it (first lint
|
|
156
|
+
warn "se: daemon not running; starting it (first lint may take a few seconds)..."
|
|
157
157
|
spawn_daemon
|
|
158
158
|
daemon = wait_for { info }
|
|
159
159
|
end
|
|
@@ -185,10 +185,9 @@ module SimpleEnglish
|
|
|
185
185
|
"(#{VERSION}). #{fix} Linting against it meanwhile."
|
|
186
186
|
end
|
|
187
187
|
|
|
188
|
-
# A per-user lock keyed by the port
|
|
189
|
-
#
|
|
190
|
-
#
|
|
191
|
-
# coordinate. flock releases when the block ends, even on
|
|
188
|
+
# A per-user lock keyed by the port: daemon ownership is scoped
|
|
189
|
+
# to the port, so concurrent lints coordinate across installs.
|
|
190
|
+
# flock releases when the block ends, even on
|
|
192
191
|
# failure.
|
|
193
192
|
def with_spawn_lock
|
|
194
193
|
dir = File.join(Dir.home, ".cache", "simple_english")
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# The LanguageTool wire protocol: form posts to /v2/check, and
|
|
4
4
|
# matches mapped back to findings. Runs daemon-side (Engine calls it
|
|
5
|
-
# against the inner
|
|
5
|
+
# against the inner native server) and in tests. The se daemon client - probe,
|
|
6
6
|
# trust, boot - lives in client/daemon.rb.
|
|
7
7
|
#
|
|
8
8
|
# The LanguageTool namespace is shared with setup/languagetool.rb,
|
|
@@ -14,6 +14,7 @@ require "net/http"
|
|
|
14
14
|
require "uri"
|
|
15
15
|
|
|
16
16
|
require_relative "../lint/plain_text"
|
|
17
|
+
require_relative "../lint/utf16"
|
|
17
18
|
|
|
18
19
|
module SimpleEnglish
|
|
19
20
|
module LanguageTool
|
|
@@ -71,25 +72,16 @@ module SimpleEnglish
|
|
|
71
72
|
end
|
|
72
73
|
|
|
73
74
|
def utf16_slice(text, offset, length)
|
|
74
|
-
first =
|
|
75
|
-
last =
|
|
75
|
+
first = UTF16.index(text, offset)
|
|
76
|
+
last = UTF16.index(text, offset + length)
|
|
76
77
|
text[first...last]
|
|
77
78
|
end
|
|
78
79
|
|
|
79
|
-
def utf16_index(text, offset)
|
|
80
|
-
units = 0
|
|
81
|
-
text.each_char.with_index do |char, index|
|
|
82
|
-
return index if units >= offset
|
|
83
|
-
units += (char.ord > 0xFFFF) ? 2 : 1
|
|
84
|
-
end
|
|
85
|
-
text.length
|
|
86
|
-
end
|
|
87
|
-
|
|
88
80
|
def to_payload(payload)
|
|
89
81
|
payload.is_a?(String) ? PlainText.new(payload) : payload
|
|
90
82
|
end
|
|
91
83
|
|
|
92
|
-
private_class_method :to_payload, :with_context, :utf16_slice
|
|
84
|
+
private_class_method :to_payload, :with_context, :utf16_slice
|
|
93
85
|
end
|
|
94
86
|
end
|
|
95
87
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# The complete lint engine, daemon-side. Never call SimpleEnglish.
|
|
3
|
+
# The complete lint engine, daemon-side. Never call SimpleEnglish.lint
|
|
4
4
|
# here: that call probes the daemon and recurses into the server.
|
|
5
5
|
# The engine reads nothing from disk: the caller captures the enabled
|
|
6
6
|
# rule IDs once at daemon boot (Server.start) and passes them in.
|
|
@@ -6,11 +6,9 @@
|
|
|
6
6
|
# Failures raise typed errors (PortInUse, SetupError, InnerDied,
|
|
7
7
|
# InnerTimeout). bin/se owns turning them into warnings and exit codes.
|
|
8
8
|
|
|
9
|
-
require "fileutils"
|
|
10
9
|
require "net/http"
|
|
11
10
|
require "socket"
|
|
12
11
|
require "tempfile"
|
|
13
|
-
require "tmpdir"
|
|
14
12
|
|
|
15
13
|
require_relative "../setup/install"
|
|
16
14
|
require_relative "../client/daemon"
|
|
@@ -28,19 +26,11 @@ module SimpleEnglish
|
|
|
28
26
|
end
|
|
29
27
|
|
|
30
28
|
class InnerDied < ServerError; end
|
|
29
|
+
|
|
31
30
|
class InnerTimeout < ServerError; end
|
|
32
31
|
|
|
33
32
|
module_function
|
|
34
33
|
|
|
35
|
-
# LanguageTool's HTTP server has no --rulefile flag. Custom rules
|
|
36
|
-
# load from the classpath at this exact path.
|
|
37
|
-
def stage_rules(dir)
|
|
38
|
-
target = File.join(dir, "org/languagetool/rules/en/grammar_custom.xml")
|
|
39
|
-
FileUtils.mkdir_p(File.dirname(target))
|
|
40
|
-
FileUtils.cp(SimpleEnglish::LanguageTool::RULES_FILE, target)
|
|
41
|
-
target
|
|
42
|
-
end
|
|
43
|
-
|
|
44
34
|
# Probes both loopback families: on IPv6-first resolvers a "localhost"
|
|
45
35
|
# probe falls back to IPv4 and misses an IPv6 listener.
|
|
46
36
|
def assert_port_free(port)
|
|
@@ -73,7 +63,7 @@ module SimpleEnglish
|
|
|
73
63
|
daemon = Client.info(base_url: "http://localhost:#{port}")
|
|
74
64
|
return unless daemon.is_a?(Hash)
|
|
75
65
|
stop(daemon["pid"])
|
|
76
|
-
# The outer listener closes first. The inner
|
|
66
|
+
# The outer listener closes first. The inner server takes
|
|
77
67
|
# longer. Both ports must free, or the next boot hits the inner
|
|
78
68
|
# port and dies.
|
|
79
69
|
deadline = Time.now + 10
|
|
@@ -82,7 +72,7 @@ module SimpleEnglish
|
|
|
82
72
|
port_free?(port + 1)
|
|
83
73
|
end
|
|
84
74
|
|
|
85
|
-
# Graceful only. SIGKILL skips the TERM trap and the inner
|
|
75
|
+
# Graceful only. SIGKILL skips the TERM trap and the inner server
|
|
86
76
|
# teardown. The orphan still holds port + 1 and breaks the next
|
|
87
77
|
# boot. The pid comes off the wire, so only a positive integer is
|
|
88
78
|
# TERMed: 0 signals the caller's own process group, a negative
|
|
@@ -98,9 +88,7 @@ module SimpleEnglish
|
|
|
98
88
|
# takeover stops the old daemon: a reload that cannot boot must
|
|
99
89
|
# leave the running daemon alive.
|
|
100
90
|
def preflight_install(install)
|
|
101
|
-
|
|
102
|
-
raise Install::SetupError,
|
|
103
|
-
install.java? ? install.setup_error : install.java_message
|
|
91
|
+
install.executable!
|
|
104
92
|
end
|
|
105
93
|
private_class_method :preflight_install
|
|
106
94
|
|
|
@@ -122,7 +110,7 @@ module SimpleEnglish
|
|
|
122
110
|
args = ["--port", port.to_s]
|
|
123
111
|
args.concat(["--dev-log", dev_log]) if dev_log
|
|
124
112
|
pid = Client.spawn_daemon(*args)
|
|
125
|
-
# The child gives its inner
|
|
113
|
+
# The child gives its inner server TIMEOUT_SECONDS to boot, and
|
|
126
114
|
# the handshake answers only after that: wait the child's own
|
|
127
115
|
# budget, not the default 90 s, or a slow boot reads as
|
|
128
116
|
# failure while the child is still coming up.
|
|
@@ -145,23 +133,19 @@ module SimpleEnglish
|
|
|
145
133
|
end
|
|
146
134
|
|
|
147
135
|
def start(port: Client::DEFAULT_PORT, install: Install.from_env, log: $stderr)
|
|
148
|
-
rules_dir = Dir.mktmpdir("se-rules")
|
|
149
|
-
# The enabled IDs come from the staged file, so what LT loads
|
|
150
|
-
# and what each request enables can never diverge.
|
|
151
|
-
staged = stage_rules(rules_dir)
|
|
152
136
|
enabled_rules = SimpleEnglish::LanguageTool.rule_ids
|
|
153
137
|
daemon_info = {"version" => VERSION, "pid" => Process.pid,
|
|
154
138
|
"gem_digest" => Fingerprint.gem,
|
|
155
|
-
"rules_digest" => Fingerprint.sha(File.read(
|
|
139
|
+
"rules_digest" => Fingerprint.sha(File.read(LanguageTool::RULES_FILE))}
|
|
156
140
|
# Preflight everything the replacement needs before the
|
|
157
141
|
# takeover stops the old daemon.
|
|
158
142
|
preflight_install(install)
|
|
159
143
|
takeover(port)
|
|
160
144
|
assert_port_free(port)
|
|
161
|
-
# The inner
|
|
145
|
+
# The inner native server lives on port + 1. Guard it too so an occupied
|
|
162
146
|
# inner port raises in milliseconds instead of timing out later.
|
|
163
147
|
assert_port_free(port + 1)
|
|
164
|
-
# The inner
|
|
148
|
+
# The inner native server's stderr goes to a file so failure messages can quote
|
|
165
149
|
# its first line. Only an explicitly opened dev log (a File) is reused
|
|
166
150
|
# for that. $stderr reports path "<STDERR>", so it creates a file
|
|
167
151
|
# by that name in the CWD. Anything else falls back to a temp file
|
|
@@ -174,7 +158,7 @@ module SimpleEnglish
|
|
|
174
158
|
inner_log.close
|
|
175
159
|
inner_log.path
|
|
176
160
|
end
|
|
177
|
-
inner = spawn_inner(install: install, port: port + 1,
|
|
161
|
+
inner = spawn_inner(install: install, port: port + 1,
|
|
178
162
|
log_path: inner_log_path)
|
|
179
163
|
# TCPServer.new binds synchronously: construction means ready, so
|
|
180
164
|
# callers need no readiness polling. Closing the socket makes the
|
|
@@ -216,7 +200,7 @@ module SimpleEnglish
|
|
|
216
200
|
break
|
|
217
201
|
end
|
|
218
202
|
monitor.kill if monitor.alive?
|
|
219
|
-
raise InnerDied, "
|
|
203
|
+
raise InnerDied, "lint engine died. Rerun se serve." if inner_died
|
|
220
204
|
ensure
|
|
221
205
|
if inner
|
|
222
206
|
begin
|
|
@@ -227,12 +211,11 @@ module SimpleEnglish
|
|
|
227
211
|
end
|
|
228
212
|
end
|
|
229
213
|
monitor&.kill if monitor&.alive?
|
|
230
|
-
FileUtils.remove_entry(rules_dir) if rules_dir
|
|
231
214
|
end
|
|
232
215
|
|
|
233
216
|
# Reaps the inner process and runs on_death (which stops the outer
|
|
234
217
|
# server) whenever it exits, so the daemon dies instead of serving
|
|
235
|
-
# without
|
|
218
|
+
# without the lint engine behind it.
|
|
236
219
|
def monitor_inner(pid, on_death:)
|
|
237
220
|
Thread.new do
|
|
238
221
|
Process.wait(pid)
|
|
@@ -240,35 +223,33 @@ module SimpleEnglish
|
|
|
240
223
|
end
|
|
241
224
|
end
|
|
242
225
|
|
|
243
|
-
# Boots the inner
|
|
226
|
+
# Boots the inner native server and blocks until it answers
|
|
244
227
|
# /v2/check. Raises InnerDied when it dies during startup, and
|
|
245
228
|
# InnerTimeout when it never becomes ready (the child is killed and
|
|
246
|
-
# reaped first, so a failed boot leaks no
|
|
229
|
+
# reaped first, so a failed boot leaks no process). Both messages quote
|
|
247
230
|
# the inner log's first line.
|
|
248
|
-
def spawn_inner(install:, port:,
|
|
249
|
-
|
|
250
|
-
java = install.java!
|
|
231
|
+
def spawn_inner(install:, port:, log_path:)
|
|
232
|
+
executable = install.executable!
|
|
251
233
|
begin
|
|
252
|
-
pid = Process.spawn(
|
|
253
|
-
"-cp", [install.server_jar, rules_dir].join(File::PATH_SEPARATOR),
|
|
254
|
-
"org.languagetool.server.HTTPServer", "--port", port.to_s,
|
|
234
|
+
pid = Process.spawn(executable, "--port", port.to_s,
|
|
255
235
|
out: File::NULL, err: log_path)
|
|
256
236
|
rescue Errno::ENOENT
|
|
257
|
-
|
|
258
|
-
# Quote what was resolved so the crash names its cause.
|
|
259
|
-
raise ServerError, "cannot exec #{java.inspect}."
|
|
237
|
+
raise ServerError, "cannot exec #{executable.inspect}."
|
|
260
238
|
end
|
|
261
239
|
ready = false
|
|
262
240
|
died = false
|
|
263
241
|
deadline = Time.now + SimpleEnglish::LanguageTool::TIMEOUT_SECONDS
|
|
264
242
|
until ready || died || Time.now > deadline
|
|
265
|
-
# A
|
|
243
|
+
# A process that dies instantly must not be polled for the full timeout.
|
|
266
244
|
# WNOHANG reaps it here, so nothing else may wait on this pid after.
|
|
267
245
|
died = !!Process.wait(pid, Process::WNOHANG)
|
|
268
246
|
break if died
|
|
269
247
|
begin
|
|
270
248
|
Net::HTTP.post_form(URI("http://localhost:#{port}/v2/check"),
|
|
271
|
-
{"language" => "en",
|
|
249
|
+
{"language" => "en",
|
|
250
|
+
"enabledRules" => SimpleEnglish::LanguageTool.rule_ids.first,
|
|
251
|
+
"enabledOnly" => "true",
|
|
252
|
+
"text" => "a"})
|
|
272
253
|
ready = true
|
|
273
254
|
rescue SystemCallError
|
|
274
255
|
sleep 0.5
|
|
@@ -276,13 +257,13 @@ module SimpleEnglish
|
|
|
276
257
|
end
|
|
277
258
|
if died
|
|
278
259
|
raise InnerDied,
|
|
279
|
-
"
|
|
260
|
+
"lint engine exited during startup. " \
|
|
280
261
|
"First log line: #{first_log_line(log_path)}"
|
|
281
262
|
end
|
|
282
263
|
unless ready
|
|
283
264
|
kill_and_reap(pid)
|
|
284
265
|
raise InnerTimeout,
|
|
285
|
-
"
|
|
266
|
+
"lint engine did not start " \
|
|
286
267
|
"within #{SimpleEnglish::LanguageTool::TIMEOUT_SECONDS} seconds. " \
|
|
287
268
|
"First log line: #{first_log_line(log_path)}"
|
|
288
269
|
end
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
require_relative "extractor"
|
|
10
10
|
require_relative "segment"
|
|
11
11
|
require_relative "result"
|
|
12
|
+
require_relative "utf16"
|
|
12
13
|
|
|
13
14
|
module SimpleEnglish
|
|
14
15
|
module AnnotatedText
|
|
@@ -21,10 +22,12 @@ module SimpleEnglish
|
|
|
21
22
|
def build(source, spans)
|
|
22
23
|
segments = []
|
|
23
24
|
cursor = 0 # byte offset into source
|
|
25
|
+
stream_start = 0 # character offset into the annotation stream
|
|
24
26
|
emit = lambda do |text:, content:, file_char:, interpret_as: nil|
|
|
25
27
|
next if content.empty?
|
|
26
28
|
segments << Segment.new(text: text, content: content, file_char: file_char,
|
|
27
|
-
stream_start:
|
|
29
|
+
stream_start: stream_start, interpret_as: interpret_as)
|
|
30
|
+
stream_start += content.length
|
|
28
31
|
end
|
|
29
32
|
spans = spans.sort_by(&:start_byte)
|
|
30
33
|
spans.each do |span|
|
|
@@ -66,20 +69,20 @@ module SimpleEnglish
|
|
|
66
69
|
|
|
67
70
|
# -> [line, column], both 1-based. Column in UTF-16 code units.
|
|
68
71
|
def locate(result, utf16_offset)
|
|
69
|
-
char =
|
|
72
|
+
char = UTF16.index(result.stream, utf16_offset)
|
|
70
73
|
segment = result.segments.find do |s|
|
|
71
74
|
char >= s.stream_start && char < s.stream_start + s.content.length
|
|
72
75
|
end || result.segments.last
|
|
73
76
|
file_char = segment.file_char + (char - segment.stream_start)
|
|
74
77
|
starts = line_starts(result.source)
|
|
75
78
|
line = starts.rindex { |start| start <= file_char } + 1
|
|
76
|
-
[line,
|
|
79
|
+
[line, UTF16.column(result.source, starts[line - 1], file_char)]
|
|
77
80
|
end
|
|
78
81
|
|
|
79
82
|
def split(comment)
|
|
80
83
|
marker = comment[MARKER] || ""
|
|
81
84
|
body = comment[marker.length..] || comment
|
|
82
|
-
closer = body[CLOSER] || ""
|
|
85
|
+
closer = body[CLOSER, 1] || ""
|
|
83
86
|
body = body[0, body.length - closer.length] unless closer.empty?
|
|
84
87
|
[marker, body, closer]
|
|
85
88
|
end
|
|
@@ -96,22 +99,6 @@ module SimpleEnglish
|
|
|
96
99
|
starts
|
|
97
100
|
end
|
|
98
101
|
|
|
99
|
-
|
|
100
|
-
units = 0
|
|
101
|
-
stream.each_char.with_index do |char, index|
|
|
102
|
-
return index if units >= offset
|
|
103
|
-
units += (char.ord > 0xFFFF) ? 2 : 1
|
|
104
|
-
end
|
|
105
|
-
stream.length
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
def utf16_column(source, from, to)
|
|
109
|
-
units = 1
|
|
110
|
-
source[from...to].each_char { |c| units += (c.ord > 0xFFFF) ? 2 : 1 }
|
|
111
|
-
units
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
private_class_method :split, :char_index, :line_starts,
|
|
115
|
-
:utf16_to_char, :utf16_column
|
|
102
|
+
private_class_method :split, :char_index, :line_starts
|
|
116
103
|
end
|
|
117
104
|
end
|
|
@@ -25,16 +25,60 @@ module SimpleEnglish
|
|
|
25
25
|
".cc" => "cpp",
|
|
26
26
|
".cxx" => "cpp",
|
|
27
27
|
".hpp" => "cpp",
|
|
28
|
-
".h" => "cpp"
|
|
28
|
+
".h" => "cpp",
|
|
29
|
+
".c" => "c",
|
|
30
|
+
".m" => "objc",
|
|
31
|
+
".php" => "php",
|
|
32
|
+
".swift" => "swift",
|
|
33
|
+
".scala" => "scala",
|
|
34
|
+
".ex" => "elixir",
|
|
35
|
+
".exs" => "elixir",
|
|
36
|
+
".lua" => "lua",
|
|
37
|
+
".dart" => "dart",
|
|
38
|
+
".pl" => "perl",
|
|
39
|
+
".pm" => "perl",
|
|
40
|
+
".r" => "r",
|
|
41
|
+
".sql" => "sql",
|
|
42
|
+
".hs" => "haskell",
|
|
43
|
+
".zig" => "zig",
|
|
44
|
+
".toml" => "toml",
|
|
45
|
+
".ini" => "ini",
|
|
46
|
+
".mk" => "makefile",
|
|
47
|
+
".html" => "html",
|
|
48
|
+
".htm" => "html",
|
|
49
|
+
".xml" => "xml"
|
|
29
50
|
}.freeze
|
|
30
51
|
|
|
52
|
+
# Dockerfile and Makefile carry no extension, and the variants
|
|
53
|
+
# keep the language: Dockerfile.dev, Makefile.dev.
|
|
54
|
+
NAME_LANGUAGES = {
|
|
55
|
+
"Dockerfile" => "dockerfile",
|
|
56
|
+
"Makefile" => "makefile",
|
|
57
|
+
"makefile" => "makefile",
|
|
58
|
+
"GNUmakefile" => "makefile"
|
|
59
|
+
}.freeze
|
|
60
|
+
|
|
61
|
+
PROSE_EXTENSIONS = [".md"].freeze
|
|
62
|
+
|
|
31
63
|
module_function
|
|
32
64
|
|
|
33
65
|
# nil for files we do not lint.
|
|
34
66
|
def language_for(path)
|
|
67
|
+
name = File.basename(path)
|
|
68
|
+
return NAME_LANGUAGES[name] if NAME_LANGUAGES[name]
|
|
69
|
+
return "dockerfile" if name.start_with?("Dockerfile.")
|
|
70
|
+
return "makefile" if name.start_with?("Makefile.")
|
|
35
71
|
EXTENSION_LANGUAGES[File.extname(path)]
|
|
36
72
|
end
|
|
37
73
|
|
|
74
|
+
# true when the path is a file this linter owns: Markdown, stdin,
|
|
75
|
+
# or a supported code language. Everything else is skipped, so a
|
|
76
|
+
# single explicit path behaves like directory expansion (cli.rb).
|
|
77
|
+
def lintable?(path)
|
|
78
|
+
path == "-" || PROSE_EXTENSIONS.include?(File.extname(path)) ||
|
|
79
|
+
!language_for(path).nil?
|
|
80
|
+
end
|
|
81
|
+
|
|
38
82
|
def comment_spans(source, language)
|
|
39
83
|
require "tree_sitter_language_pack"
|
|
40
84
|
root = TreeSitterLanguagePack.get_parser(language).parse(source).root_node
|
|
@@ -51,8 +95,11 @@ module SimpleEnglish
|
|
|
51
95
|
spans.sort_by!(&:start_byte)
|
|
52
96
|
end
|
|
53
97
|
|
|
98
|
+
# The xml grammar names the node "Comment", so the kind check
|
|
99
|
+
# reads case-insensitive.
|
|
54
100
|
def comment?(node)
|
|
55
|
-
|
|
101
|
+
kind = node.kind.downcase
|
|
102
|
+
kind.end_with?("comment") && !kind.start_with?("non")
|
|
56
103
|
end
|
|
57
104
|
|
|
58
105
|
private_class_method :comment?
|
|
@@ -5,5 +5,20 @@
|
|
|
5
5
|
# daemon can have a start column without an end position.
|
|
6
6
|
|
|
7
7
|
module SimpleEnglish
|
|
8
|
-
Finding = Struct.new(:line, :column, :end_line, :end_column, :rule, :message)
|
|
8
|
+
Finding = Struct.new(:line, :column, :end_line, :end_column, :rule, :message) do
|
|
9
|
+
# One finding as a text line: `path:line:col-end: [rule] message`.
|
|
10
|
+
# The CLI text output, the MCP tool, and the hook feedback all share
|
|
11
|
+
# this format. Keep them in step by rendering through this method.
|
|
12
|
+
def to_line(path)
|
|
13
|
+
location = "#{path}:#{line}"
|
|
14
|
+
if column
|
|
15
|
+
location += ":#{column}"
|
|
16
|
+
if end_line && end_column
|
|
17
|
+
finish = (end_line == line) ? end_column : "#{end_line}:#{end_column}"
|
|
18
|
+
location += "-#{finish}"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
"#{location}: [#{rule}] #{message}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
9
24
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# One decision, two callers: the CLI (SimpleEnglish.
|
|
3
|
+
# One decision, two callers: the CLI (SimpleEnglish.lint) and
|
|
4
4
|
# the daemon (Engine.lint) must agree on what kind of lint an input
|
|
5
5
|
# is. The CLI decides to skip comment-free files without a boot.
|
|
6
6
|
# The daemon decides again on its side of the wire. Both call this
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleEnglish
|
|
4
|
+
module Linter
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def lint_file(path, config: Config.load)
|
|
8
|
+
return [] if Config.ignore?(config, path) || !Extractor.lintable?(path)
|
|
9
|
+
lint_text(File.read(path, encoding: "UTF-8"), language: Extractor.language_for(path), config: config, path: path)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def lint_text(text, language: nil, config: Config.load, path: nil)
|
|
13
|
+
findings = SimpleEnglish.lint(text, language: language)
|
|
14
|
+
findings && Config.filter(config, findings, path: path)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "paragraph"
|
|
4
|
+
require_relative "utf16"
|
|
4
5
|
|
|
5
6
|
# Markdown stripping and paragraph splitting. Block structure comes
|
|
6
7
|
# from the tree-sitter Markdown grammar shipped with the pack the
|
|
@@ -42,16 +43,12 @@ module SimpleEnglish
|
|
|
42
43
|
# Width-preserving replacements keep LanguageTool positions aligned
|
|
43
44
|
# with the source.
|
|
44
45
|
line
|
|
45
|
-
.gsub(/`[^`]*`/) { |code| "X" *
|
|
46
|
+
.gsub(/`[^`]*`/) { |code| "X" * UTF16.length(code) }
|
|
46
47
|
.sub(/\A\#{1,6} /) { |marker| " " * marker.length }
|
|
47
48
|
end
|
|
48
49
|
|
|
49
50
|
def spaces_for(text)
|
|
50
|
-
" " *
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def utf16_length(text)
|
|
54
|
-
text.each_char.sum { |char| (char.ord > 0xFFFF) ? 2 : 1 }
|
|
51
|
+
" " * UTF16.length(text)
|
|
55
52
|
end
|
|
56
53
|
|
|
57
54
|
# A vertical list is not one paragraph: each list item is its own.
|
|
@@ -115,7 +112,7 @@ module SimpleEnglish
|
|
|
115
112
|
TreeSitterLanguagePack.get_parser("markdown").parse(text).root_node
|
|
116
113
|
end
|
|
117
114
|
|
|
118
|
-
private_class_method :strip_line, :spaces_for, :
|
|
115
|
+
private_class_method :strip_line, :spaces_for, :node_rows,
|
|
119
116
|
:blank_rows, :each_node, :inside_list_item?, :parse
|
|
120
117
|
end
|
|
121
118
|
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# UTF-16 code-unit arithmetic. LanguageTool is a Java service, and
|
|
4
|
+
# Java strings count UTF-16 code units: an astral-plane character
|
|
5
|
+
# counts as two. Every offset crossing the LT wire goes through
|
|
6
|
+
# here.
|
|
7
|
+
|
|
8
|
+
module SimpleEnglish
|
|
9
|
+
module UTF16
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# The character index in text where the offsetth code unit
|
|
13
|
+
# falls.
|
|
14
|
+
def index(text, offset)
|
|
15
|
+
units = 0
|
|
16
|
+
text.each_char.with_index do |char, index|
|
|
17
|
+
return index if units >= offset
|
|
18
|
+
units += (char.ord > 0xFFFF) ? 2 : 1
|
|
19
|
+
end
|
|
20
|
+
text.length
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The code-unit length of text.
|
|
24
|
+
def length(text)
|
|
25
|
+
text.each_char.sum { |char| (char.ord > 0xFFFF) ? 2 : 1 }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The 1-based code-unit column of the character at index to.
|
|
29
|
+
def column(source, from, to)
|
|
30
|
+
length(source[from...to]) + 1
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
# MCP JSON-RPC server for the lint engine. `respond` is pure: request
|
|
6
|
+
# Hash in, response Hash or nil (notifications) out. `run` drives it
|
|
7
|
+
# over stdio, one JSON-RPC message per line. Protocol facts:
|
|
8
|
+
# integrations/verification.md.
|
|
9
|
+
|
|
10
|
+
module SimpleEnglish
|
|
11
|
+
module ModelContextProtocol
|
|
12
|
+
SUPPORTED_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"].freeze
|
|
13
|
+
LATEST_VERSION = "2025-06-18"
|
|
14
|
+
SERVER_NAME = "simple-english"
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def run(io: $stdin, out: $stdout, linter: Linter)
|
|
19
|
+
# A piped stdout buffers until 4 KB or EOF. An MCP client keeps
|
|
20
|
+
# stdin open while it waits for the answer, so an unflushed
|
|
21
|
+
# response never arrives and the client drops the server.
|
|
22
|
+
out.sync = true
|
|
23
|
+
while (line = io.gets)
|
|
24
|
+
line = line.strip
|
|
25
|
+
next if line.empty?
|
|
26
|
+
begin
|
|
27
|
+
request = JSON.parse(line)
|
|
28
|
+
rescue JSON::ParserError
|
|
29
|
+
out.puts JSON.generate(error(nil, -32700, "Parse error"))
|
|
30
|
+
next
|
|
31
|
+
end
|
|
32
|
+
next unless request.is_a?(Hash)
|
|
33
|
+
response =
|
|
34
|
+
begin
|
|
35
|
+
respond(request, linter: linter)
|
|
36
|
+
rescue => e
|
|
37
|
+
# A broken tool call (unreadable file, daemon hiccup) must not
|
|
38
|
+
# kill the loop: answer it, then serve the next line.
|
|
39
|
+
request.key?("id") ? error(request["id"], -32603, "lint failed: #{e.class}: #{e.message}") : nil
|
|
40
|
+
end
|
|
41
|
+
out.puts JSON.generate(response) if response
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def respond(request, linter:)
|
|
46
|
+
return nil unless request.key?("id")
|
|
47
|
+
id = request.fetch("id")
|
|
48
|
+
method = request["method"]
|
|
49
|
+
return error(id, -32600, "Invalid Request") unless method.is_a?(String)
|
|
50
|
+
params = request["params"]
|
|
51
|
+
params = {} unless params.is_a?(Hash)
|
|
52
|
+
case method
|
|
53
|
+
when "initialize" then ok(id, initialize_result(params["protocolVersion"]))
|
|
54
|
+
when "ping" then ok(id, {})
|
|
55
|
+
when "tools/list" then ok(id, {"tools" => [lint_tool]})
|
|
56
|
+
when "tools/call" then tools_call(id, params, linter)
|
|
57
|
+
else error(id, -32601, "Method not found: #{method}")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def initialize_result(requested_version)
|
|
62
|
+
version = SUPPORTED_VERSIONS.include?(requested_version) ? requested_version : LATEST_VERSION
|
|
63
|
+
{
|
|
64
|
+
"protocolVersion" => version,
|
|
65
|
+
"capabilities" => {"tools" => {}},
|
|
66
|
+
"serverInfo" => {"name" => SERVER_NAME, "version" => SimpleEnglish::VERSION}
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def lint_tool
|
|
71
|
+
{
|
|
72
|
+
"name" => "lint",
|
|
73
|
+
"description" =>
|
|
74
|
+
"Lint Markdown prose and code comments against plain-English rules " \
|
|
75
|
+
"(short sentences, active voice, no jargon, no filler). " \
|
|
76
|
+
"Input: a file path, or Markdown text. Returns one line per finding, or 'clean' when " \
|
|
77
|
+
"nothing is flagged. Fix the findings, then call again until clean.",
|
|
78
|
+
"inputSchema" => {
|
|
79
|
+
"type" => "object",
|
|
80
|
+
"properties" => {
|
|
81
|
+
"path" => {"type" => "string", "description" => "File to lint"},
|
|
82
|
+
"text" => {"type" => "string", "description" => "Markdown text to lint instead of a file"}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def tools_call(id, params, linter)
|
|
89
|
+
tool = params["name"]
|
|
90
|
+
return error(id, -32602, "Unknown tool: #{tool}") unless tool == "lint"
|
|
91
|
+
arguments = params["arguments"]
|
|
92
|
+
arguments = {} unless arguments.is_a?(Hash)
|
|
93
|
+
path = arguments["path"]
|
|
94
|
+
text = arguments["text"]
|
|
95
|
+
return ok(id, tool_error("give the path or the text, not both.")) if path && text
|
|
96
|
+
unless text
|
|
97
|
+
return ok(id, tool_error("the path or text argument is required.")) if path.nil? || path.to_s.empty?
|
|
98
|
+
return ok(id, tool_error("#{path} is a directory. Give one file.")) if File.directory?(path)
|
|
99
|
+
return ok(id, tool_error("file not found: #{path}")) unless File.exist?(path)
|
|
100
|
+
end
|
|
101
|
+
findings =
|
|
102
|
+
begin
|
|
103
|
+
text ? linter.lint_text(text) : linter.lint_file(path)
|
|
104
|
+
rescue SimpleEnglish::Config::ConfigError => e
|
|
105
|
+
return ok(id, tool_error("invalid .simple-english.yml: #{e.message}"))
|
|
106
|
+
end
|
|
107
|
+
if findings.nil?
|
|
108
|
+
# A tool error, not a protocol error: clients show the text to the
|
|
109
|
+
# model, and this message is the one that tells it how to recover.
|
|
110
|
+
ok(id, tool_error("the se daemon did not answer. Run `se serve --detached` and retry."))
|
|
111
|
+
else
|
|
112
|
+
ok(id, {"content" => [{"type" => "text", "text" => render(text ? "-" : path, findings)}], "isError" => false})
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def render(path, findings)
|
|
117
|
+
return "clean" if findings.empty?
|
|
118
|
+
findings.map { |finding| finding.to_line(path) }.join("\n")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def tool_error(text)
|
|
122
|
+
{"content" => [{"type" => "text", "text" => text}], "isError" => true}
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def ok(id, result)
|
|
126
|
+
{"jsonrpc" => "2.0", "id" => id, "result" => result}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def error(id, code, message)
|
|
130
|
+
{"jsonrpc" => "2.0", "id" => id, "error" => {"code" => code, "message" => message}}
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|