simple_english 0.3.0 → 0.4.0
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 +47 -31
- data/docs/RULES.md +1 -1
- data/lib/simple_english/cli.rb +36 -13
- data/lib/simple_english/client/daemon.rb +225 -0
- data/lib/simple_english/client/language_tool.rb +95 -0
- data/lib/simple_english/daemon/engine.rb +40 -0
- data/lib/simple_english/{http.rb → daemon/http.rb} +10 -4
- data/lib/simple_english/{server.rb → daemon/server.rb} +113 -7
- data/lib/simple_english/lint/lint_plan.rb +22 -0
- data/lib/simple_english/lint/plain_text.rb +30 -0
- data/lib/simple_english/{result.rb → lint/result.rb} +1 -1
- data/lib/simple_english/{config.rb → setup/config.rb} +3 -2
- data/lib/simple_english/setup/fingerprint.rb +38 -0
- data/lib/simple_english/{install.rb → setup/install.rb} +19 -9
- data/lib/simple_english/setup/languagetool.rb +226 -0
- data/lib/simple_english/version.rb +1 -1
- data/lib/simple_english.rb +25 -43
- metadata +39 -36
- data/lib/simple_english/client.rb +0 -166
- data/lib/simple_english/engine.rb +0 -31
- data/lib/simple_english/languagetool.rb +0 -143
- data/lib/simple_english/plain_text.rb +0 -17
- /data/lib/simple_english/{annotated_text.rb → lint/annotated_text.rb} +0 -0
- /data/lib/simple_english/{counts.rb → lint/counts.rb} +0 -0
- /data/lib/simple_english/{extractor.rb → lint/extractor.rb} +0 -0
- /data/lib/simple_english/{finding.rb → lint/finding.rb} +0 -0
- /data/lib/simple_english/{markdown.rb → lint/markdown.rb} +0 -0
- /data/lib/simple_english/{paragraph.rb → lint/paragraph.rb} +0 -0
- /data/lib/simple_english/{segment.rb → lint/segment.rb} +0 -0
- /data/lib/simple_english/{span.rb → lint/span.rb} +0 -0
- /data/lib/simple_english/{suppressions.rb → lint/suppressions.rb} +0 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# The se daemon: process lifecycle only. The wire protocol lives in
|
|
4
|
-
# lib/simple_english/http.rb, the lint engine in
|
|
4
|
+
# lib/simple_english/daemon/http.rb, the lint engine in
|
|
5
|
+
# lib/simple_english/daemon/engine.rb.
|
|
5
6
|
# Failures raise typed errors (PortInUse, SetupError, InnerDied,
|
|
6
7
|
# InnerTimeout). bin/se owns turning them into warnings and exit codes.
|
|
7
8
|
|
|
@@ -11,7 +12,10 @@ require "socket"
|
|
|
11
12
|
require "tempfile"
|
|
12
13
|
require "tmpdir"
|
|
13
14
|
|
|
14
|
-
require_relative "install"
|
|
15
|
+
require_relative "../setup/install"
|
|
16
|
+
require_relative "../client/daemon"
|
|
17
|
+
require_relative "../setup/fingerprint"
|
|
18
|
+
require_relative "../version"
|
|
15
19
|
|
|
16
20
|
module SimpleEnglish
|
|
17
21
|
module Server
|
|
@@ -40,24 +44,123 @@ module SimpleEnglish
|
|
|
40
44
|
# Probes both loopback families: on IPv6-first resolvers a "localhost"
|
|
41
45
|
# probe falls back to IPv4 and misses an IPv6 listener.
|
|
42
46
|
def assert_port_free(port)
|
|
47
|
+
raise PortInUse, port unless port_free?(port)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def port_free?(port)
|
|
43
51
|
["127.0.0.1", "::1"].each do |address|
|
|
44
52
|
TCPServer.new(address, port).close
|
|
45
|
-
rescue Errno::EADDRNOTAVAIL
|
|
53
|
+
rescue Errno::EADDRNOTAVAIL, Errno::EAFNOSUPPORT
|
|
54
|
+
# One family may be missing on this host (EADDRNOTAVAIL, or
|
|
55
|
+
# EAFNOSUPPORT when the kernel has no IPv6 at all): skip it
|
|
56
|
+
# and probe the other. A rescue at method scope returns here
|
|
57
|
+
# and never probes the second family.
|
|
46
58
|
next
|
|
47
59
|
end
|
|
60
|
+
true
|
|
48
61
|
rescue Errno::EADDRINUSE
|
|
49
|
-
|
|
62
|
+
false
|
|
50
63
|
rescue Errno::EACCES
|
|
51
64
|
raise ServerError, "port #{port} cannot be bound. Use a port above 1024."
|
|
52
65
|
end
|
|
53
66
|
|
|
67
|
+
# `se serve` over a live daemon (gem upgrade): when
|
|
68
|
+
# one of our daemons holds the port (it answers the
|
|
69
|
+
# handshake), stop it and take over the port. A service that
|
|
70
|
+
# does not answer the handshake, pre-handshake daemon included,
|
|
71
|
+
# keeps the PortInUse error: those need a manual look.
|
|
72
|
+
def takeover(port)
|
|
73
|
+
daemon = Client.info(base_url: "http://localhost:#{port}")
|
|
74
|
+
return unless daemon.is_a?(Hash)
|
|
75
|
+
stop(daemon["pid"])
|
|
76
|
+
# The outer listener closes first. The inner JVM takes seconds
|
|
77
|
+
# longer. Both ports must free, or the next boot hits the inner
|
|
78
|
+
# port and dies.
|
|
79
|
+
deadline = Time.now + 10
|
|
80
|
+
sleep 0.2 until Time.now > deadline ||
|
|
81
|
+
!Client.info(base_url: "http://localhost:#{port}").is_a?(Hash) &&
|
|
82
|
+
port_free?(port + 1)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Graceful only. SIGKILL skips the TERM trap and the inner JVM
|
|
86
|
+
# teardown. The orphan still holds port + 1 and breaks the next
|
|
87
|
+
# boot. The pid comes off the wire, so only a positive integer is
|
|
88
|
+
# TERMed: 0 signals the caller's own process group, a negative
|
|
89
|
+
# one every process the user may signal.
|
|
90
|
+
def stop(pid)
|
|
91
|
+
return nil unless pid.is_a?(Integer) && pid > 1
|
|
92
|
+
Process.kill("TERM", pid)
|
|
93
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Everything a boot needs on the machine, checked before the
|
|
98
|
+
# takeover stops the old daemon: a reload that cannot boot must
|
|
99
|
+
# leave the running daemon alive.
|
|
100
|
+
def preflight_install(install)
|
|
101
|
+
return if File.exist?(install.server_jar) && install.java?
|
|
102
|
+
raise Install::SetupError,
|
|
103
|
+
install.java? ? install.setup_error : install.java_message
|
|
104
|
+
end
|
|
105
|
+
private_class_method :preflight_install
|
|
106
|
+
|
|
107
|
+
# `se serve --detached`: replace any se daemon on the port, then
|
|
108
|
+
# boot a plain foreground serve as a detached child - the same
|
|
109
|
+
# shape the auto-boot spawns. The parent returns once the
|
|
110
|
+
# handshake answers. nil means the child never came up, and the
|
|
111
|
+
# visible serve (same command, no flag) shows why. dev_log is
|
|
112
|
+
# forwarded so a detached daemon can still get a log file.
|
|
113
|
+
def start_detached(port: Client::DEFAULT_PORT, install: Install.from_env,
|
|
114
|
+
dev_log: nil)
|
|
115
|
+
preflight_install(install)
|
|
116
|
+
takeover(port)
|
|
117
|
+
# A foreign port holder survives the takeover untouched: fail
|
|
118
|
+
# in milliseconds like the foreground boot, not after the
|
|
119
|
+
# 90 s spawn timeout.
|
|
120
|
+
assert_port_free(port)
|
|
121
|
+
assert_port_free(port + 1)
|
|
122
|
+
args = ["--port", port.to_s]
|
|
123
|
+
args.concat(["--dev-log", dev_log]) if dev_log
|
|
124
|
+
pid = Client.spawn_daemon(*args)
|
|
125
|
+
# The child gives its inner JVM TIMEOUT_SECONDS to boot, and
|
|
126
|
+
# the handshake answers only after that: wait the child's own
|
|
127
|
+
# budget, not the default 90 s, or a slow boot reads as
|
|
128
|
+
# failure while the child is still coming up.
|
|
129
|
+
daemon = Client.wait_for(
|
|
130
|
+
seconds: SimpleEnglish::LanguageTool::TIMEOUT_SECONDS + 10
|
|
131
|
+
) { Client.info(base_url: "http://localhost:#{port}") }
|
|
132
|
+
return daemon if daemon.is_a?(Hash)
|
|
133
|
+
# Readiness failed. The child is either dead (InnerTimeout and
|
|
134
|
+
# InnerDied make it exit itself) or a straggler that later
|
|
135
|
+
# takes over the port from whatever daemon comes next.
|
|
136
|
+
# Stop it either way, and reap so nothing is left behind.
|
|
137
|
+
stop(pid)
|
|
138
|
+
begin
|
|
139
|
+
Process.wait(pid)
|
|
140
|
+
rescue SystemCallError
|
|
141
|
+
# The child already exited on its own (InnerTimeout,
|
|
142
|
+
# InnerDied): only the reap is left.
|
|
143
|
+
end
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
|
|
54
147
|
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
|
+
enabled_rules = SimpleEnglish::LanguageTool.rule_ids
|
|
153
|
+
daemon_info = {"version" => VERSION, "pid" => Process.pid,
|
|
154
|
+
"gem_digest" => Fingerprint.gem,
|
|
155
|
+
"rules_digest" => Fingerprint.sha(File.read(staged))}
|
|
156
|
+
# Preflight everything the replacement needs before the
|
|
157
|
+
# takeover stops the old daemon.
|
|
158
|
+
preflight_install(install)
|
|
159
|
+
takeover(port)
|
|
55
160
|
assert_port_free(port)
|
|
56
161
|
# The inner JVM lives on port + 1. Guard it too so an occupied
|
|
57
162
|
# inner port raises in milliseconds instead of timing out later.
|
|
58
163
|
assert_port_free(port + 1)
|
|
59
|
-
rules_dir = Dir.mktmpdir("se-rules")
|
|
60
|
-
stage_rules(rules_dir)
|
|
61
164
|
# The inner JVM's stderr goes to a file so failure messages can quote
|
|
62
165
|
# its first line. Only an explicitly opened dev log (a File) is reused
|
|
63
166
|
# for that. $stderr reports path "<STDERR>", so it creates a file
|
|
@@ -103,7 +206,10 @@ module SimpleEnglish
|
|
|
103
206
|
# Select timeout on an idle socket: loop back and select again.
|
|
104
207
|
next if ready.nil?
|
|
105
208
|
client = server.accept
|
|
106
|
-
Thread.new(client) { |c|
|
|
209
|
+
Thread.new(client) { |c|
|
|
210
|
+
HTTP.handle_client(c, port: port,
|
|
211
|
+
enabled_rules: enabled_rules, info: daemon_info)
|
|
212
|
+
}
|
|
107
213
|
rescue IOError, Errno::EBADF
|
|
108
214
|
# A trap or the inner-death monitor closed the listener. This
|
|
109
215
|
# happens during the select, or between select and accept.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# One decision, two callers: the CLI (SimpleEnglish.lint_file) and
|
|
4
|
+
# the daemon (Engine.lint) must agree on what kind of lint an input
|
|
5
|
+
# is. The CLI decides to skip comment-free files without a boot.
|
|
6
|
+
# The daemon decides again on its side of the wire. Both call this
|
|
7
|
+
# module, so the two tiers cannot drift.
|
|
8
|
+
|
|
9
|
+
module SimpleEnglish
|
|
10
|
+
module LintPlan
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Returns :prose when there is no language (the Markdown
|
|
14
|
+
# pipeline), nil for a comment-free code file (nothing to lint:
|
|
15
|
+
# no boot, no POST), or the comment spans for a code file.
|
|
16
|
+
def call(text, language)
|
|
17
|
+
return :prose unless language
|
|
18
|
+
spans = SimpleEnglish::Extractor.comment_spans(text, language)
|
|
19
|
+
spans.empty? ? nil : spans
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleEnglish
|
|
4
|
+
# The plain-text payload for /v2/check, duck-compatible with
|
|
5
|
+
# AnnotatedText::Result: #lt_params and #locate.
|
|
6
|
+
PlainText = Struct.new(:text) do
|
|
7
|
+
def lt_params
|
|
8
|
+
{"text" => text}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# LanguageTool reports offsets in Java UTF-16 code units.
|
|
12
|
+
# Return a 1-based line and UTF-16 column for a 0-based offset.
|
|
13
|
+
def locate(utf16_offset)
|
|
14
|
+
line = 1
|
|
15
|
+
column = 1
|
|
16
|
+
units = 0
|
|
17
|
+
text.each_char do |char|
|
|
18
|
+
return [line, column] if units >= utf16_offset
|
|
19
|
+
units += (char.ord > 0xFFFF) ? 2 : 1
|
|
20
|
+
if char == "\n"
|
|
21
|
+
line += 1
|
|
22
|
+
column = 1
|
|
23
|
+
else
|
|
24
|
+
column += (char.ord > 0xFFFF) ? 2 : 1
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
[line, column]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module SimpleEnglish
|
|
4
4
|
module AnnotatedText
|
|
5
5
|
# The payload for code comments. Duck interface shared with
|
|
6
|
-
#
|
|
6
|
+
# PlainText: #lt_params (the form data for LanguageTool)
|
|
7
7
|
# and #locate (a match offset back to file line and column).
|
|
8
8
|
Result = Struct.new(:source, :segments, :stream) do
|
|
9
9
|
def lt_params
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# .simple-english.yml: ignore (path globs, matched against the paths
|
|
4
|
-
# on the command line) and disabled-rules
|
|
3
|
+
# .simple-english.yml: ignore (path globs, matched against the paths
|
|
4
|
+
# given on the command line) and disabled-rules (rule IDs dropped
|
|
5
|
+
# from every file). Loaded from the CWD.
|
|
5
6
|
|
|
6
7
|
module SimpleEnglish
|
|
7
8
|
module Config
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Fingerprints for the daemon handshake. `gem` identifies the code and
|
|
4
|
+
# built-in rules a daemon runs: any release that changes lint behavior
|
|
5
|
+
# changes it, and nothing else does. `sha` covers the staged rule
|
|
6
|
+
# content wherever the caller resolved it.
|
|
7
|
+
|
|
8
|
+
require_relative "languagetool"
|
|
9
|
+
|
|
10
|
+
module SimpleEnglish
|
|
11
|
+
module Fingerprint
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Hash everything the daemon can execute, not a curated list: a
|
|
15
|
+
# curated list goes stale the first time a new file is forgotten,
|
|
16
|
+
# and a missed file means silent staleness, the exact bug the
|
|
17
|
+
# handshake exists to catch. The accepted cost: a release that
|
|
18
|
+
# only changes caller-side code triggers one needless restart.
|
|
19
|
+
# ponytail: whole-lib glob. If restarts ever hurt, curate per-file.
|
|
20
|
+
def gem
|
|
21
|
+
files = Dir.glob(File.expand_path("../../**/*.rb", __dir__)).sort
|
|
22
|
+
files << LanguageTool::RULES_FILE
|
|
23
|
+
# Each file is length-prefixed: bare concatenation cannot tell
|
|
24
|
+
# ["ab", "c"] from ["a", "bc"], so bytes moved across a file
|
|
25
|
+
# boundary leave the digest unchanged.
|
|
26
|
+
framed = files.map do |path|
|
|
27
|
+
content = File.read(path)
|
|
28
|
+
"#{content.bytesize}\n#{content}"
|
|
29
|
+
end
|
|
30
|
+
sha(framed.join)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def sha(content)
|
|
34
|
+
require "digest"
|
|
35
|
+
::Digest::SHA256.hexdigest(content)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -13,11 +13,12 @@ module SimpleEnglish
|
|
|
13
13
|
class SetupError < StandardError
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
attr_reader :cache_dir, :java
|
|
16
|
+
attr_reader :cache_dir, :java, :java_source
|
|
17
17
|
|
|
18
|
-
def initialize(cache_dir:, java: nil)
|
|
18
|
+
def initialize(cache_dir:, java: nil, java_source: nil)
|
|
19
19
|
@cache_dir = cache_dir
|
|
20
20
|
@java = java
|
|
21
|
+
@java_source = java_source
|
|
21
22
|
end
|
|
22
23
|
|
|
23
24
|
# The one place the environment is read. SE_JAVA wins outright,
|
|
@@ -25,15 +26,21 @@ module SimpleEnglish
|
|
|
25
26
|
# Otherwise the code probes the PATH candidate by running it,
|
|
26
27
|
# because a file can exist and still be the macOS stub that
|
|
27
28
|
# reports no runtime. The last resort is the Homebrew location,
|
|
28
|
-
# which sits outside PATH.
|
|
29
|
+
# which sits outside PATH. java_source records which of the three
|
|
30
|
+
# won, so `se setup` can say so.
|
|
29
31
|
def self.from_env(env = ENV)
|
|
30
32
|
cache = File.expand_path(env.fetch(LanguageTool::CACHE_DIR_ENV) do
|
|
31
33
|
File.join(Dir.home, ".cache", "se")
|
|
32
34
|
end)
|
|
33
|
-
java =
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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)
|
|
37
44
|
end
|
|
38
45
|
|
|
39
46
|
# Runs the candidate under the given PATH, isolated from this
|
|
@@ -50,8 +57,11 @@ module SimpleEnglish
|
|
|
50
57
|
|
|
51
58
|
def java!
|
|
52
59
|
return java if java?
|
|
53
|
-
raise SetupError,
|
|
54
|
-
|
|
60
|
+
raise SetupError, java_message
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def java_message
|
|
64
|
+
"java not found. Install a JRE (on macOS: brew install openjdk), " \
|
|
55
65
|
"or set SE_JAVA to your java binary."
|
|
56
66
|
end
|
|
57
67
|
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The pinned LanguageTool distro: its version, its rules file, and the
|
|
4
|
+
# downloader. Everything that resolves "where it is on this machine and
|
|
5
|
+
# which java runs it" lives in Install. The engine runs as the daemon's
|
|
6
|
+
# inner HTTP server (lib/simple_english/daemon/server.rb).
|
|
7
|
+
|
|
8
|
+
module SimpleEnglish
|
|
9
|
+
module LanguageTool
|
|
10
|
+
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
|
+
LT_VERSION = "6.6"
|
|
15
|
+
CACHE_DIR_ENV = "SE_CACHE_DIR"
|
|
16
|
+
JAVA_ENV = "SE_JAVA"
|
|
17
|
+
# macOS ships a /usr/bin/java stub that reports no runtime. Homebrew
|
|
18
|
+
# installs the real one here, outside PATH.
|
|
19
|
+
HOMEBREW_JAVA = "/opt/homebrew/opt/openjdk/bin/java"
|
|
20
|
+
TIMEOUT_SECONDS = 300
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
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
|
+
def rule_ids(path = RULES_FILE)
|
|
221
|
+
File.read(path).scan(/<(?:rule|rulegroup)\b[^>]*>/).filter_map do |tag|
|
|
222
|
+
tag[/\bid="([^"]+)"/, 1]
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
data/lib/simple_english.rb
CHANGED
|
@@ -5,19 +5,23 @@
|
|
|
5
5
|
# This file is the composition root. The pieces live in lib/simple_english/.
|
|
6
6
|
|
|
7
7
|
require_relative "simple_english/version"
|
|
8
|
-
require_relative "simple_english/finding"
|
|
9
|
-
require_relative "simple_english/
|
|
10
|
-
require_relative "simple_english/
|
|
11
|
-
require_relative "simple_english/
|
|
12
|
-
require_relative "simple_english/
|
|
13
|
-
require_relative "simple_english/
|
|
14
|
-
require_relative "simple_english/
|
|
15
|
-
require_relative "simple_english/
|
|
16
|
-
require_relative "simple_english/
|
|
17
|
-
require_relative "simple_english/
|
|
18
|
-
require_relative "simple_english/
|
|
19
|
-
require_relative "simple_english/
|
|
20
|
-
require_relative "simple_english/
|
|
8
|
+
require_relative "simple_english/lint/finding"
|
|
9
|
+
require_relative "simple_english/lint/paragraph"
|
|
10
|
+
require_relative "simple_english/lint/markdown"
|
|
11
|
+
require_relative "simple_english/lint/counts"
|
|
12
|
+
require_relative "simple_english/setup/languagetool"
|
|
13
|
+
require_relative "simple_english/setup/install"
|
|
14
|
+
require_relative "simple_english/lint/extractor"
|
|
15
|
+
require_relative "simple_english/lint/lint_plan"
|
|
16
|
+
require_relative "simple_english/lint/annotated_text"
|
|
17
|
+
require_relative "simple_english/lint/suppressions"
|
|
18
|
+
require_relative "simple_english/setup/config"
|
|
19
|
+
require_relative "simple_english/setup/fingerprint"
|
|
20
|
+
require_relative "simple_english/client/language_tool"
|
|
21
|
+
require_relative "simple_english/client/daemon"
|
|
22
|
+
require_relative "simple_english/daemon/engine"
|
|
23
|
+
require_relative "simple_english/daemon/http"
|
|
24
|
+
require_relative "simple_english/daemon/server"
|
|
21
25
|
|
|
22
26
|
module SimpleEnglish
|
|
23
27
|
module_function
|
|
@@ -30,48 +34,26 @@ module SimpleEnglish
|
|
|
30
34
|
Client.lint(text)
|
|
31
35
|
end
|
|
32
36
|
|
|
33
|
-
# One entry point for files.
|
|
34
|
-
#
|
|
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:
|
|
35
40
|
# the caller decides how fatal that is.
|
|
36
41
|
def lint_file(path, text = File.read(path))
|
|
37
42
|
language = Extractor.language_for(path)
|
|
43
|
+
plan = LintPlan.call(text, language)
|
|
44
|
+
return [] if plan.nil?
|
|
38
45
|
findings =
|
|
39
|
-
if
|
|
40
|
-
|
|
41
|
-
|
|
46
|
+
if plan == :prose
|
|
47
|
+
lint_text(text)
|
|
48
|
+
else
|
|
42
49
|
return nil unless Client.ensure_up(install: Install.from_env)
|
|
43
50
|
result = Client.lint(text, language: language)
|
|
44
51
|
if result.nil?
|
|
45
52
|
warn "error: se daemon did not answer. Run `se serve` and read its output."
|
|
46
53
|
end
|
|
47
54
|
result
|
|
48
|
-
else
|
|
49
|
-
lint_text(text)
|
|
50
55
|
end
|
|
51
56
|
return nil unless findings
|
|
52
57
|
Suppressions.filter(text, findings)
|
|
53
58
|
end
|
|
54
|
-
|
|
55
|
-
def corpus_test
|
|
56
|
-
pairs = Dir.glob(File.expand_path("../test/corpus/*-before.md", __dir__))
|
|
57
|
-
.sort
|
|
58
|
-
failed = pairs.flat_map do |before|
|
|
59
|
-
after = before.sub(/-before\.md\z/, "-after.md")
|
|
60
|
-
before_findings = lint_text(File.read(before))
|
|
61
|
-
after_findings = lint_text(File.read(after))
|
|
62
|
-
messages = []
|
|
63
|
-
if before_findings.nil? || after_findings.nil?
|
|
64
|
-
messages << "#{before}: daemon unreachable"
|
|
65
|
-
else
|
|
66
|
-
messages << "#{before}: no findings, the rule set misses this case" if before_findings.empty?
|
|
67
|
-
unless after_findings.empty?
|
|
68
|
-
messages << "#{after}: still flagged: #{after_findings.map(&:rule).join(", ")}"
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
messages
|
|
72
|
-
end
|
|
73
|
-
failed.each { |failure| puts "FAIL #{failure}" }
|
|
74
|
-
puts "corpus: #{pairs.size} pairs, #{failed.size} failures"
|
|
75
|
-
failed.empty?
|
|
76
|
-
end
|
|
77
59
|
end
|