simple_english 0.6.0-x86_64-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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +212 -0
  4. data/THIRD_PARTY_NOTICES.md +72 -0
  5. data/bin/se +6 -0
  6. data/docs/RULES.md +496 -0
  7. data/lib/simple_english/cli.rb +183 -0
  8. data/lib/simple_english/client/daemon.rb +233 -0
  9. data/lib/simple_english/client/language_tool.rb +95 -0
  10. data/lib/simple_english/daemon/engine.rb +40 -0
  11. data/lib/simple_english/daemon/http.rb +82 -0
  12. data/lib/simple_english/daemon/server.rb +285 -0
  13. data/lib/simple_english/lint/annotated_text.rb +117 -0
  14. data/lib/simple_english/lint/counts.rb +40 -0
  15. data/lib/simple_english/lint/extractor.rb +60 -0
  16. data/lib/simple_english/lint/finding.rb +24 -0
  17. data/lib/simple_english/lint/lint_plan.rb +22 -0
  18. data/lib/simple_english/lint/markdown.rb +121 -0
  19. data/lib/simple_english/lint/paragraph.rb +10 -0
  20. data/lib/simple_english/lint/plain_text.rb +30 -0
  21. data/lib/simple_english/lint/result.rb +18 -0
  22. data/lib/simple_english/lint/segment.rb +11 -0
  23. data/lib/simple_english/lint/span.rb +8 -0
  24. data/lib/simple_english/lint/suppressions.rb +37 -0
  25. data/lib/simple_english/model_context_protocol.rb +134 -0
  26. data/lib/simple_english/setup/config.rb +63 -0
  27. data/lib/simple_english/setup/fingerprint.rb +38 -0
  28. data/lib/simple_english/setup/install.rb +40 -0
  29. data/lib/simple_english/setup/languagetool.rb +23 -0
  30. data/lib/simple_english/version.rb +5 -0
  31. data/lib/simple_english.rb +60 -0
  32. data/libexec/simple_english/languagetool-server +4 -0
  33. data/libexec/simple_english/languagetool-server.sbom.json +9044 -0
  34. data/licenses/LGPL-2.1.txt +501 -0
  35. data/licenses/OpenJDK-LICENSE.txt +347 -0
  36. data/licenses/languagetool-resource-en/12dicts-readme.html +738 -0
  37. data/licenses/languagetool-resource-en/agid-readme.txt +340 -0
  38. data/licenses/languagetool-resource-en/pos-readme.txt +115 -0
  39. data/rules/simple-english.xml +621 -0
  40. 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