simple_english 0.2.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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +61 -39
  3. data/docs/RULES.md +1 -1
  4. data/lib/simple_english/cli.rb +53 -15
  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/{extractor.rb → lint/extractor.rb} +6 -1
  11. data/lib/simple_english/lint/finding.rb +9 -0
  12. data/lib/simple_english/lint/lint_plan.rb +22 -0
  13. data/lib/simple_english/{markdown.rb → lint/markdown.rb} +28 -10
  14. data/lib/simple_english/lint/plain_text.rb +30 -0
  15. data/lib/simple_english/{result.rb → lint/result.rb} +1 -1
  16. data/lib/simple_english/{config.rb → setup/config.rb} +3 -2
  17. data/lib/simple_english/setup/fingerprint.rb +38 -0
  18. data/lib/simple_english/{install.rb → setup/install.rb} +19 -9
  19. data/lib/simple_english/setup/languagetool.rb +226 -0
  20. data/lib/simple_english/version.rb +1 -1
  21. data/lib/simple_english.rb +25 -43
  22. data/rules/simple-english.xml +5 -1
  23. metadata +42 -36
  24. data/lib/simple_english/client.rb +0 -165
  25. data/lib/simple_english/engine.rb +0 -31
  26. data/lib/simple_english/finding.rb +0 -8
  27. data/lib/simple_english/languagetool.rb +0 -143
  28. data/lib/simple_english/plain_text.rb +0 -18
  29. /data/lib/simple_english/{annotated_text.rb → lint/annotated_text.rb} +0 -0
  30. /data/lib/simple_english/{counts.rb → lint/counts.rb} +0 -0
  31. /data/lib/simple_english/{paragraph.rb → lint/paragraph.rb} +0 -0
  32. /data/lib/simple_english/{segment.rb → lint/segment.rb} +0 -0
  33. /data/lib/simple_english/{span.rb → lint/span.rb} +0 -0
  34. /data/lib/simple_english/{suppressions.rb → lint/suppressions.rb} +0 -0
@@ -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
@@ -43,14 +43,20 @@ module SimpleEnglish
43
43
  end
44
44
 
45
45
  # One thread per request: lint can take seconds, so a slow request
46
- # must not block readiness probes or other clients.
47
- def handle_client(client, port:)
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: {})
48
50
  request = read_request(client)
49
51
  if request.nil?
50
52
  # Malformed request or immediate hangup: nothing to answer.
51
53
  elsif request[:method] == "HEAD"
52
- # Client.up? does http.head("/") and treats any response as up.
54
+ # Older CLIs probe with HEAD and treat any response as up.
53
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))
54
60
  elsif request[:method] == "POST" && request[:path] == "/lint"
55
61
  begin
56
62
  # Ruling 2026-09-24: Client.lint posts form-encoded data, so the
@@ -60,7 +66,7 @@ module SimpleEnglish
60
66
  write_response(client, status: 200,
61
67
  body: SimpleEnglish::Engine.lint_json(params["text"],
62
68
  base_url: "http://localhost:#{port + 1}",
63
- language: params["language"]))
69
+ language: params["language"], enabled_rules: enabled_rules))
64
70
  rescue => e
65
71
  write_response(client, status: 500,
66
72
  body: JSON.generate({"error" => e.message}))
@@ -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.
@@ -20,7 +20,12 @@ module SimpleEnglish
20
20
  ".java" => "java",
21
21
  ".sh" => "bash",
22
22
  ".kt" => "kotlin",
23
- ".cs" => "csharp"
23
+ ".cs" => "csharp",
24
+ ".cpp" => "cpp",
25
+ ".cc" => "cpp",
26
+ ".cxx" => "cpp",
27
+ ".hpp" => "cpp",
28
+ ".h" => "cpp"
24
29
  }.freeze
25
30
 
26
31
  module_function
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # One lint result. Positions are 1-based. The end position is exclusive.
4
+ # Counting findings have no columns or end positions. Findings from an older
5
+ # daemon can have a start column without an end position.
6
+
7
+ module SimpleEnglish
8
+ Finding = Struct.new(:line, :column, :end_line, :end_column, :rule, :message)
9
+ end
@@ -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
@@ -21,21 +21,39 @@ module SimpleEnglish
21
21
  module_function
22
22
 
23
23
  # Blank out code and other non-prose blocks and neutralize inline
24
- # code and heading markers. The line count stays identical to the
25
- # source.
24
+ # code and heading markers. Line count and UTF-16 widths stay identical
25
+ # to the source so LanguageTool offsets remain source positions.
26
26
  def strip(text)
27
- lines = text.lines.map(&:chomp)
28
- blank_rows(text).each { |row| lines[row] = "" }
29
- lines.map { |line| strip_line(line) }.join("\n")
27
+ blank = blank_rows(text).to_h { |row| [row, true] }
28
+ text.lines.each_with_index.map do |line, row|
29
+ ending = if line.end_with?("\r\n")
30
+ "\r\n"
31
+ elsif line.end_with?("\n")
32
+ "\n"
33
+ else
34
+ ""
35
+ end
36
+ body = line.delete_suffix(ending)
37
+ (blank[row] ? spaces_for(body) : strip_line(body)) + ending
38
+ end.join
30
39
  end
31
40
 
32
41
  def strip_line(line)
33
- # Width-preserving replacements, so a finding column points at
34
- # the source line, not at the stripped copy.
35
- line.gsub(/`[^`]*`/) { |code| "X" * code.length }
42
+ # Width-preserving replacements keep LanguageTool positions aligned
43
+ # with the source.
44
+ line
45
+ .gsub(/`[^`]*`/) { |code| "X" * utf16_length(code) }
36
46
  .sub(/\A\#{1,6} /) { |marker| " " * marker.length }
37
47
  end
38
48
 
49
+ def spaces_for(text)
50
+ " " * utf16_length(text)
51
+ end
52
+
53
+ def utf16_length(text)
54
+ text.each_char.sum { |char| (char.ord > 0xFFFF) ? 2 : 1 }
55
+ end
56
+
39
57
  # A vertical list is not one paragraph: each list item is its own.
40
58
  # Paragraph boundaries come from the tree-sitter Markdown grammar,
41
59
  # so lazy continuations and interrupted lists match the spec
@@ -97,7 +115,7 @@ module SimpleEnglish
97
115
  TreeSitterLanguagePack.get_parser("markdown").parse(text).root_node
98
116
  end
99
117
 
100
- private_class_method :strip_line, :node_rows, :blank_rows, :each_node,
101
- :inside_list_item?, :parse
118
+ private_class_method :strip_line, :spaces_for, :utf16_length, :node_rows,
119
+ :blank_rows, :each_node, :inside_list_item?, :parse
102
120
  end
103
121
  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