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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +47 -31
  3. data/docs/RULES.md +1 -1
  4. data/lib/simple_english/cli.rb +36 -13
  5. data/lib/simple_english/client/daemon.rb +225 -0
  6. data/lib/simple_english/client/language_tool.rb +95 -0
  7. data/lib/simple_english/daemon/engine.rb +40 -0
  8. data/lib/simple_english/{http.rb → daemon/http.rb} +10 -4
  9. data/lib/simple_english/{server.rb → daemon/server.rb} +113 -7
  10. data/lib/simple_english/lint/lint_plan.rb +22 -0
  11. data/lib/simple_english/lint/plain_text.rb +30 -0
  12. data/lib/simple_english/{result.rb → lint/result.rb} +1 -1
  13. data/lib/simple_english/{config.rb → setup/config.rb} +3 -2
  14. data/lib/simple_english/setup/fingerprint.rb +38 -0
  15. data/lib/simple_english/{install.rb → setup/install.rb} +19 -9
  16. data/lib/simple_english/setup/languagetool.rb +226 -0
  17. data/lib/simple_english/version.rb +1 -1
  18. data/lib/simple_english.rb +25 -43
  19. metadata +39 -36
  20. data/lib/simple_english/client.rb +0 -166
  21. data/lib/simple_english/engine.rb +0 -31
  22. data/lib/simple_english/languagetool.rb +0 -143
  23. data/lib/simple_english/plain_text.rb +0 -17
  24. /data/lib/simple_english/{annotated_text.rb → lint/annotated_text.rb} +0 -0
  25. /data/lib/simple_english/{counts.rb → lint/counts.rb} +0 -0
  26. /data/lib/simple_english/{extractor.rb → lint/extractor.rb} +0 -0
  27. /data/lib/simple_english/{finding.rb → lint/finding.rb} +0 -0
  28. /data/lib/simple_english/{markdown.rb → lint/markdown.rb} +0 -0
  29. /data/lib/simple_english/{paragraph.rb → lint/paragraph.rb} +0 -0
  30. /data/lib/simple_english/{segment.rb → lint/segment.rb} +0 -0
  31. /data/lib/simple_english/{span.rb → lint/span.rb} +0 -0
  32. /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 lib/simple_english/engine.rb.
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
- raise PortInUse, port
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| HTTP.handle_client(c, port: port) }
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
- # Client::PlainText: #lt_params (the form data for LanguageTool)
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 given
4
- # on the command line) and disabled-rules. Loaded from the CWD.
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 = env[LanguageTool::JAVA_ENV] ||
34
- ("java" if probe?("java", env)) ||
35
- (LanguageTool::HOMEBREW_JAVA if File.executable?(LanguageTool::HOMEBREW_JAVA))
36
- new(cache_dir: cache, java: java)
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
- "java not found. Install a JRE (on macOS: brew install openjdk), " \
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SimpleEnglish
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -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/markdown"
10
- require_relative "simple_english/counts"
11
- require_relative "simple_english/languagetool"
12
- require_relative "simple_english/install"
13
- require_relative "simple_english/extractor"
14
- require_relative "simple_english/annotated_text"
15
- require_relative "simple_english/suppressions"
16
- require_relative "simple_english/config"
17
- require_relative "simple_english/engine"
18
- require_relative "simple_english/http"
19
- require_relative "simple_english/client"
20
- require_relative "simple_english/server"
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. Markdown keeps the counting rules. Code
34
- # files lint comments through the daemon. nil means unreachable:
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 language
40
- # Comment-free files never reach the daemon. No boot, no POST.
41
- return [] if Extractor.comment_spans(text, language).empty?
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