simple_english 0.6.0-aarch64-linux
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +212 -0
- data/THIRD_PARTY_NOTICES.md +72 -0
- data/bin/se +6 -0
- data/docs/RULES.md +496 -0
- data/lib/simple_english/cli.rb +183 -0
- data/lib/simple_english/client/daemon.rb +233 -0
- data/lib/simple_english/client/language_tool.rb +95 -0
- data/lib/simple_english/daemon/engine.rb +40 -0
- data/lib/simple_english/daemon/http.rb +82 -0
- data/lib/simple_english/daemon/server.rb +285 -0
- data/lib/simple_english/lint/annotated_text.rb +117 -0
- data/lib/simple_english/lint/counts.rb +40 -0
- data/lib/simple_english/lint/extractor.rb +60 -0
- data/lib/simple_english/lint/finding.rb +24 -0
- data/lib/simple_english/lint/lint_plan.rb +22 -0
- data/lib/simple_english/lint/markdown.rb +121 -0
- data/lib/simple_english/lint/paragraph.rb +10 -0
- data/lib/simple_english/lint/plain_text.rb +30 -0
- data/lib/simple_english/lint/result.rb +18 -0
- data/lib/simple_english/lint/segment.rb +11 -0
- data/lib/simple_english/lint/span.rb +8 -0
- data/lib/simple_english/lint/suppressions.rb +37 -0
- data/lib/simple_english/model_context_protocol.rb +134 -0
- data/lib/simple_english/setup/config.rb +63 -0
- data/lib/simple_english/setup/fingerprint.rb +38 -0
- data/lib/simple_english/setup/install.rb +40 -0
- data/lib/simple_english/setup/languagetool.rb +23 -0
- data/lib/simple_english/version.rb +5 -0
- data/lib/simple_english.rb +60 -0
- data/libexec/simple_english/languagetool-server +4 -0
- data/libexec/simple_english/languagetool-server.sbom.json +9044 -0
- 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 +621 -0
- metadata +185 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# HTTP client for the se daemon: probe, handshake, boot, lint. The
|
|
4
|
+
# LanguageTool wire protocol lives in lib/simple_english/client/language_tool.rb.
|
|
5
|
+
|
|
6
|
+
require "json"
|
|
7
|
+
require "net/http"
|
|
8
|
+
require "rbconfig"
|
|
9
|
+
require "fileutils"
|
|
10
|
+
require "uri"
|
|
11
|
+
|
|
12
|
+
require_relative "../setup/fingerprint"
|
|
13
|
+
require_relative "../version"
|
|
14
|
+
|
|
15
|
+
module SimpleEnglish
|
|
16
|
+
module Client
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
DEFAULT_PORT = 8181
|
|
20
|
+
REQUEST_TIMEOUT = 30
|
|
21
|
+
|
|
22
|
+
# The executable that daemon advice tells you to run. Resolved
|
|
23
|
+
# from this file, so it names the code that is actually loaded:
|
|
24
|
+
# a checkout names its bin/se, a gem install names its bundled
|
|
25
|
+
# bin/se. $PROGRAM_NAME is wrong for library callers: their host
|
|
26
|
+
# script is not se.
|
|
27
|
+
def runner
|
|
28
|
+
File.expand_path("../../../bin/se", __dir__)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def url
|
|
32
|
+
ENV.fetch("SE_SERVER_URL") { "http://localhost:#{DEFAULT_PORT}" }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def post(uri, params, read_timeout: REQUEST_TIMEOUT)
|
|
36
|
+
Net::HTTP.start(uri.host, uri.port, open_timeout: 5,
|
|
37
|
+
read_timeout: read_timeout) do |http|
|
|
38
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
|
39
|
+
request.set_form_data(params)
|
|
40
|
+
http.request(request)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private_class_method :post
|
|
45
|
+
|
|
46
|
+
# Full lint via the se daemon. Raw Markdown in, or code
|
|
47
|
+
# source with a language for the comment pipeline. nil when
|
|
48
|
+
# unreachable or the response is unusable, so callers can fall
|
|
49
|
+
# back without seeing a stack trace.
|
|
50
|
+
def lint(text, base_url: url, language: nil)
|
|
51
|
+
params = {"text" => text}
|
|
52
|
+
params["language"] = language if language
|
|
53
|
+
response = post(URI("#{base_url}/lint"), params)
|
|
54
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
55
|
+
body = JSON.parse(response.body)
|
|
56
|
+
return nil unless body.is_a?(Array)
|
|
57
|
+
body.map do |hash|
|
|
58
|
+
Finding.new(line: hash.fetch("line"), column: hash["column"],
|
|
59
|
+
end_line: hash["end_line"], end_column: hash["end_column"],
|
|
60
|
+
rule: hash.fetch("rule"), message: hash.fetch("message"))
|
|
61
|
+
end
|
|
62
|
+
rescue SystemCallError, SocketError, Timeout::Error,
|
|
63
|
+
JSON::ParserError, TypeError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The daemon handshake: GET / answers {version, pid, gem_digest,
|
|
68
|
+
# rules_digest}. Returns the handshake Hash for an se daemon,
|
|
69
|
+
# :foreign for a reachable responder that is not one (an older
|
|
70
|
+
# release, a foreign service) - those still lint, the caller
|
|
71
|
+
# cannot check them - or nil when nothing answers. Unreachable
|
|
72
|
+
# means boot. Foreign means lint but say so.
|
|
73
|
+
def info(base_url: url)
|
|
74
|
+
uri = URI(base_url)
|
|
75
|
+
response = Net::HTTP.start(uri.host, uri.port, open_timeout: 1,
|
|
76
|
+
read_timeout: 2) { |http| http.get("/") }
|
|
77
|
+
return :foreign unless response.is_a?(Net::HTTPSuccess)
|
|
78
|
+
data = JSON.parse(response.body)
|
|
79
|
+
# The whole contract or nothing: a responder with a string pid
|
|
80
|
+
# or a missing field is not a daemon we can reason about.
|
|
81
|
+
return :foreign unless data.is_a?(Hash) &&
|
|
82
|
+
data["version"].is_a?(String) && data["pid"].is_a?(Integer) &&
|
|
83
|
+
data["pid"].positive? && data["gem_digest"].is_a?(String) &&
|
|
84
|
+
data["rules_digest"].is_a?(String)
|
|
85
|
+
data
|
|
86
|
+
rescue Errno::ECONNRESET, Errno::EPIPE, EOFError, Net::ReadTimeout,
|
|
87
|
+
JSON::ParserError, TypeError
|
|
88
|
+
# Something holds the port but answers nothing usable. A read
|
|
89
|
+
# timeout is that too: the request went out, nothing came back.
|
|
90
|
+
:foreign
|
|
91
|
+
rescue Errno::ECONNREFUSED, SocketError, Timeout::Error
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# True when the daemon answers. Starts it when the port is
|
|
96
|
+
# cold and we own the default URL. A running daemon is never
|
|
97
|
+
# replaced by a lint: `se serve` owns that. A custom
|
|
98
|
+
# SE_SERVER_URL belongs to someone else, so never spawn against
|
|
99
|
+
# it. It returns false when unusable. The caller owns the exit
|
|
100
|
+
# status. Diagnostics go to stderr here, where the cause is
|
|
101
|
+
# known.
|
|
102
|
+
def ensure_up(install: SimpleEnglish::Install.from_env)
|
|
103
|
+
daemon = info
|
|
104
|
+
return boot(install) if daemon.nil?
|
|
105
|
+
check_daemon(daemon)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# An answered probe, classified and said out loud: a foreign
|
|
109
|
+
# responder or a different build. Returns true: there is a daemon
|
|
110
|
+
# to lint against, so the lint proceeds. Every path that holds a
|
|
111
|
+
# handshake result goes through here, so no caller can lint
|
|
112
|
+
# silently.
|
|
113
|
+
def check_daemon(daemon)
|
|
114
|
+
# Reachable but not handshake-capable (an older release, a
|
|
115
|
+
# foreign service): lint against it, there is nothing to check
|
|
116
|
+
# or replace automatically. Say so: a silent stale lint is the
|
|
117
|
+
# bug the handshake exists to catch.
|
|
118
|
+
if daemon == :foreign
|
|
119
|
+
if ENV["SE_SERVER_URL"]
|
|
120
|
+
warn "se: daemon at #{url} is not a handshake-capable se daemon. " \
|
|
121
|
+
"Update it (pull a newer image or rebuild). " \
|
|
122
|
+
"It is not restarted automatically."
|
|
123
|
+
else
|
|
124
|
+
warn "se: daemon at #{url} is not a handshake-capable se daemon. " \
|
|
125
|
+
"Run `#{runner} serve` to replace it."
|
|
126
|
+
end
|
|
127
|
+
return true
|
|
128
|
+
end
|
|
129
|
+
return true if daemon["gem_digest"] == Fingerprint.gem
|
|
130
|
+
warn_mismatched_code(daemon)
|
|
131
|
+
true
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The daemon is unreachable. Start it when we own the default
|
|
135
|
+
# URL, fail fast otherwise.
|
|
136
|
+
def boot(install)
|
|
137
|
+
if ENV["SE_SERVER_URL"]
|
|
138
|
+
warn "error: SE_SERVER_URL is set but #{url} does not answer."
|
|
139
|
+
return false
|
|
140
|
+
end
|
|
141
|
+
# Preflight everything the spawn needs: a missing prerequisite
|
|
142
|
+
# must fail fast with the real blocker named, not spawn a
|
|
143
|
+
# doomed child and wait out 90 s.
|
|
144
|
+
unless install.executable?
|
|
145
|
+
warn install.setup_error
|
|
146
|
+
return false
|
|
147
|
+
end
|
|
148
|
+
# One boot at a time: a concurrent lint that also found the
|
|
149
|
+
# port cold waits here, re-checks, and skips its own spawn.
|
|
150
|
+
# Whatever answers on wake is classified like the first probe:
|
|
151
|
+
# the loser must get the same warnings, never a silent lint
|
|
152
|
+
# against the winner's rules or build.
|
|
153
|
+
with_spawn_lock do
|
|
154
|
+
daemon = info
|
|
155
|
+
unless daemon
|
|
156
|
+
warn "se: daemon not running; starting it (first lint may take a few seconds)..."
|
|
157
|
+
spawn_daemon
|
|
158
|
+
daemon = wait_for { info }
|
|
159
|
+
end
|
|
160
|
+
if daemon
|
|
161
|
+
check_daemon(daemon)
|
|
162
|
+
else
|
|
163
|
+
warn "error: se daemon did not come up. Run `#{runner} serve` and read its output."
|
|
164
|
+
false
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# The daemon runs different code: older, or another build of
|
|
170
|
+
# this version (a dev checkout beside the installed gem). A
|
|
171
|
+
# lint never stops a daemon: `se serve` owns replacement. Say
|
|
172
|
+
# what runs and how to fix it, then lint against it: a stale
|
|
173
|
+
# lint with a warning beats a refused lint.
|
|
174
|
+
def warn_mismatched_code(daemon)
|
|
175
|
+
if newer_daemon?(daemon)
|
|
176
|
+
# `se serve` from this install boots an older daemon in its
|
|
177
|
+
# place. The fix is updating this gem, not the daemon.
|
|
178
|
+
warn "se: daemon at #{url} runs se #{daemon["version"]}, newer than this install " \
|
|
179
|
+
"(#{VERSION}). Update this gem. Linting against it meanwhile."
|
|
180
|
+
return
|
|
181
|
+
end
|
|
182
|
+
fix = ENV["SE_SERVER_URL"] ? "It is not restarted automatically." :
|
|
183
|
+
"Run `#{runner} serve --detached` to restart it."
|
|
184
|
+
warn "se: daemon at #{url} runs se #{daemon["version"]}, different code than this install " \
|
|
185
|
+
"(#{VERSION}). #{fix} Linting against it meanwhile."
|
|
186
|
+
end
|
|
187
|
+
|
|
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
|
|
191
|
+
# failure.
|
|
192
|
+
def with_spawn_lock
|
|
193
|
+
dir = File.join(Dir.home, ".cache", "simple_english")
|
|
194
|
+
FileUtils.mkdir_p(dir)
|
|
195
|
+
port = URI(url).port
|
|
196
|
+
File.open(File.join(dir, "spawn-#{port}.lock"), "w") do |lock|
|
|
197
|
+
lock.flock(File::LOCK_EX)
|
|
198
|
+
yield
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Boots `se serve` as a detached child, output discarded: the
|
|
203
|
+
# daemon must outlive the caller. Extra arguments (a port, a
|
|
204
|
+
# dev-log path) are forwarded to the child serve.
|
|
205
|
+
def spawn_daemon(*serve_args)
|
|
206
|
+
bin = File.expand_path("../../../bin/se", __dir__)
|
|
207
|
+
Process.spawn(RbConfig.ruby, bin, "serve", *serve_args,
|
|
208
|
+
out: File::NULL, err: File::NULL)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def wait_for(seconds: 90)
|
|
212
|
+
deadline = Time.now + seconds
|
|
213
|
+
until Time.now > deadline
|
|
214
|
+
result = yield
|
|
215
|
+
return result if result
|
|
216
|
+
sleep 0.5
|
|
217
|
+
end
|
|
218
|
+
false
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# The handshake reports the daemon's version: a digest mismatch
|
|
222
|
+
# alone cannot tell older from newer. A malformed version counts
|
|
223
|
+
# as not newer, so the warning path decides what to do with it.
|
|
224
|
+
def newer_daemon?(daemon)
|
|
225
|
+
Gem::Version.new(daemon["version"]) > Gem::Version.new(VERSION)
|
|
226
|
+
rescue ArgumentError
|
|
227
|
+
false
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
private_class_method :boot, :check_daemon, :warn_mismatched_code,
|
|
231
|
+
:newer_daemon?, :with_spawn_lock
|
|
232
|
+
end
|
|
233
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The LanguageTool wire protocol: form posts to /v2/check, and
|
|
4
|
+
# matches mapped back to findings. Runs daemon-side (Engine calls it
|
|
5
|
+
# against the inner native server) and in tests. The se daemon client - probe,
|
|
6
|
+
# trust, boot - lives in client/daemon.rb.
|
|
7
|
+
#
|
|
8
|
+
# The LanguageTool namespace is shared with setup/languagetool.rb,
|
|
9
|
+
# which holds the pinned-distro facts. This file adds the client
|
|
10
|
+
# that speaks to that distro.
|
|
11
|
+
|
|
12
|
+
require "json"
|
|
13
|
+
require "net/http"
|
|
14
|
+
require "uri"
|
|
15
|
+
|
|
16
|
+
require_relative "../lint/plain_text"
|
|
17
|
+
|
|
18
|
+
module SimpleEnglish
|
|
19
|
+
module LanguageTool
|
|
20
|
+
module Client
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
REQUEST_TIMEOUT = 30
|
|
24
|
+
|
|
25
|
+
def post(uri, params, read_timeout: REQUEST_TIMEOUT)
|
|
26
|
+
Net::HTTP.start(uri.host, uri.port, open_timeout: 5,
|
|
27
|
+
read_timeout: read_timeout) do |http|
|
|
28
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
|
29
|
+
request.set_form_data(params)
|
|
30
|
+
http.request(request)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private_class_method :post
|
|
35
|
+
|
|
36
|
+
# Pattern rules. `payload` is Markdown-stripped text (String) or an
|
|
37
|
+
# AnnotatedText::Result (code comments): one payload interface,
|
|
38
|
+
# #lt_params and #locate, either side of the daemon's LT request.
|
|
39
|
+
# The caller owns the enabled rule IDs (the daemon captures them
|
|
40
|
+
# at boot). There is no default, so no call silently lints with
|
|
41
|
+
# every rule disabled.
|
|
42
|
+
def check(payload, enabled_rules:, base_url:)
|
|
43
|
+
payload = to_payload(payload)
|
|
44
|
+
params = {"language" => "en",
|
|
45
|
+
"enabledRules" => Array(enabled_rules).join(","),
|
|
46
|
+
"enabledOnly" => "true"}.merge(payload.lt_params)
|
|
47
|
+
response = post(URI("#{base_url}/v2/check"), params)
|
|
48
|
+
parse_matches(JSON.parse(response.body).fetch("matches"), payload)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def parse_matches(matches, payload)
|
|
52
|
+
payload = to_payload(payload)
|
|
53
|
+
matches.map do |match|
|
|
54
|
+
offset = match.fetch("offset")
|
|
55
|
+
line, column = payload.locate(offset)
|
|
56
|
+
end_line, end_column = payload.locate(offset + match.fetch("length"))
|
|
57
|
+
Finding.new(line: line, column: column,
|
|
58
|
+
end_line: end_line, end_column: end_column,
|
|
59
|
+
rule: match.fetch("rule").fetch("id"),
|
|
60
|
+
message: with_context(match.fetch("message"), match))
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Prefix the offending text, so a finding says what to change,
|
|
65
|
+
# not only how. Context positions use Java UTF-16 code units.
|
|
66
|
+
def with_context(message, match)
|
|
67
|
+
context = match["context"] or return message
|
|
68
|
+
matched = utf16_slice(context.fetch("text", ""),
|
|
69
|
+
context.fetch("offset", 0).to_i, context.fetch("length", 0).to_i)
|
|
70
|
+
matched.empty? ? message : "\"#{matched}\" - #{message}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def utf16_slice(text, offset, length)
|
|
74
|
+
first = utf16_index(text, offset)
|
|
75
|
+
last = utf16_index(text, offset + length)
|
|
76
|
+
text[first...last]
|
|
77
|
+
end
|
|
78
|
+
|
|
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
|
+
def to_payload(payload)
|
|
89
|
+
payload.is_a?(String) ? PlainText.new(payload) : payload
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private_class_method :to_payload, :with_context, :utf16_slice, :utf16_index
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The complete lint engine, daemon-side. Never call SimpleEnglish.lint_text
|
|
4
|
+
# here: that call probes the daemon and recurses into the server.
|
|
5
|
+
# The engine reads nothing from disk: the caller captures the enabled
|
|
6
|
+
# rule IDs once at daemon boot (Server.start) and passes them in.
|
|
7
|
+
# LintPlan owns what kind of lint an input is. This module executes
|
|
8
|
+
# the plan.
|
|
9
|
+
|
|
10
|
+
require "json"
|
|
11
|
+
|
|
12
|
+
module SimpleEnglish
|
|
13
|
+
module Engine
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def lint(text, base_url:, language: nil,
|
|
17
|
+
enabled_rules: SimpleEnglish::LanguageTool.rule_ids)
|
|
18
|
+
plan = SimpleEnglish::LintPlan.call(text, language)
|
|
19
|
+
return [] if plan.nil?
|
|
20
|
+
if plan == :prose
|
|
21
|
+
stripped = SimpleEnglish::Markdown.strip(text)
|
|
22
|
+
(SimpleEnglish::Counts.check(stripped) +
|
|
23
|
+
SimpleEnglish::LanguageTool::Client.check(stripped, base_url: base_url,
|
|
24
|
+
enabled_rules: enabled_rules))
|
|
25
|
+
.sort_by { |finding| [finding.line, finding.rule] }
|
|
26
|
+
else
|
|
27
|
+
SimpleEnglish::LanguageTool::Client.check(
|
|
28
|
+
SimpleEnglish::AnnotatedText.build(text, plan), base_url: base_url,
|
|
29
|
+
enabled_rules: enabled_rules
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def lint_json(text, base_url:, language: nil,
|
|
35
|
+
enabled_rules: SimpleEnglish::LanguageTool.rule_ids)
|
|
36
|
+
JSON.generate(lint(text, base_url: base_url, language: language,
|
|
37
|
+
enabled_rules: enabled_rules).map(&:to_h))
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Minimal HTTP/1.1 wire framing for the daemon's outer server. Serving
|
|
4
|
+
# one request per connection (Connection: close) keeps this simple: no
|
|
5
|
+
# chunked bodies, no pipelining.
|
|
6
|
+
|
|
7
|
+
require "json"
|
|
8
|
+
|
|
9
|
+
module SimpleEnglish
|
|
10
|
+
module HTTP
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Reads one HTTP request from io: request line, headers, then
|
|
14
|
+
# Content-Length bytes of body. nil when malformed or the peer
|
|
15
|
+
# hung up.
|
|
16
|
+
def read_request(io)
|
|
17
|
+
request_line = io.gets
|
|
18
|
+
parts = request_line ? request_line.split(" ") : []
|
|
19
|
+
return nil if parts.length < 2
|
|
20
|
+
content_length = 0
|
|
21
|
+
while (line = io.gets)
|
|
22
|
+
break if line == "\r\n" || line == "\n"
|
|
23
|
+
name, value = line.split(":", 2)
|
|
24
|
+
next unless value
|
|
25
|
+
content_length = value.strip.to_i if name.casecmp?("content-length")
|
|
26
|
+
end
|
|
27
|
+
return nil if content_length.negative?
|
|
28
|
+
body = content_length.zero? ? +"" : io.read(content_length)
|
|
29
|
+
{method: parts[0], path: parts[1], body: body}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Writes a minimal HTTP/1.1 response and closes the logical
|
|
33
|
+
# connection (Connection: close): status line, always-JSON headers,
|
|
34
|
+
# Content-Length, then the body.
|
|
35
|
+
def write_response(io, status:, body:, content_type: "application/json")
|
|
36
|
+
reason = {200 => "OK", 404 => "Not Found",
|
|
37
|
+
500 => "Internal Server Error"}.fetch(status, "Unknown")
|
|
38
|
+
io.write("HTTP/1.1 #{status} #{reason}\r\n" \
|
|
39
|
+
"Content-Type: #{content_type}\r\n" \
|
|
40
|
+
"Content-Length: #{body.bytesize}\r\n" \
|
|
41
|
+
"Connection: close\r\n\r\n")
|
|
42
|
+
io.write(body)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# One thread per request: lint can take seconds, so a slow request
|
|
46
|
+
# must not block readiness probes or other clients. The enabled
|
|
47
|
+
# rule IDs and the handshake info are frozen at boot (Server.start)
|
|
48
|
+
# and passed through.
|
|
49
|
+
def handle_client(client, port:, enabled_rules:, info: {})
|
|
50
|
+
request = read_request(client)
|
|
51
|
+
if request.nil?
|
|
52
|
+
# Malformed request or immediate hangup: nothing to answer.
|
|
53
|
+
elsif request[:method] == "HEAD"
|
|
54
|
+
# Older CLIs probe with HEAD and treat any response as up.
|
|
55
|
+
write_response(client, status: 200, body: "")
|
|
56
|
+
elsif request[:method] == "GET" && request[:path] == "/"
|
|
57
|
+
# The handshake: the daemon reports its version, pid, and the
|
|
58
|
+
# gem/rules digests the client compares against its own.
|
|
59
|
+
write_response(client, status: 200, body: JSON.generate(info))
|
|
60
|
+
elsif request[:method] == "POST" && request[:path] == "/lint"
|
|
61
|
+
begin
|
|
62
|
+
# Ruling 2026-09-24: Client.lint posts form-encoded data, so the
|
|
63
|
+
# handler decodes it. A raw body lints "text=Don%27t...".
|
|
64
|
+
# The optional language switches to the code-comment pipeline.
|
|
65
|
+
params = URI.decode_www_form(request[:body]).to_h
|
|
66
|
+
write_response(client, status: 200,
|
|
67
|
+
body: SimpleEnglish::Engine.lint_json(params["text"],
|
|
68
|
+
base_url: "http://localhost:#{port + 1}",
|
|
69
|
+
language: params["language"], enabled_rules: enabled_rules))
|
|
70
|
+
rescue => e
|
|
71
|
+
write_response(client, status: 500,
|
|
72
|
+
body: JSON.generate({"error" => e.message}))
|
|
73
|
+
end
|
|
74
|
+
else
|
|
75
|
+
write_response(client, status: 404,
|
|
76
|
+
body: JSON.generate({"error" => "not found"}))
|
|
77
|
+
end
|
|
78
|
+
ensure
|
|
79
|
+
client.close
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|