simple_english 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +193 -0
- data/bin/se +6 -0
- data/docs/RULES.md +496 -0
- data/lib/simple_english/annotated_text.rb +117 -0
- data/lib/simple_english/cli.rb +152 -0
- data/lib/simple_english/client.rb +133 -0
- data/lib/simple_english/config.rb +51 -0
- data/lib/simple_english/counts.rb +40 -0
- data/lib/simple_english/engine.rb +31 -0
- data/lib/simple_english/extractor.rb +55 -0
- data/lib/simple_english/finding.rb +8 -0
- data/lib/simple_english/http.rb +76 -0
- data/lib/simple_english/install.rb +75 -0
- data/lib/simple_english/languagetool.rb +143 -0
- data/lib/simple_english/markdown.rb +97 -0
- data/lib/simple_english/paragraph.rb +10 -0
- data/lib/simple_english/plain_text.rb +17 -0
- data/lib/simple_english/result.rb +18 -0
- data/lib/simple_english/segment.rb +11 -0
- data/lib/simple_english/server.rb +196 -0
- data/lib/simple_english/span.rb +8 -0
- data/lib/simple_english/suppressions.rb +37 -0
- data/lib/simple_english.rb +78 -0
- data/rules/simple-english.xml +617 -0
- metadata +168 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The resolved LanguageTool installation: where the jars live and which
|
|
4
|
+
# java runs them. Build it once from the environment at the process
|
|
5
|
+
# edge with Install.from_env. Everything downstream takes the value,
|
|
6
|
+
# never the environment. A nil java means "not found": ask with java?
|
|
7
|
+
# or fail with java! and get the fix in the message.
|
|
8
|
+
|
|
9
|
+
require_relative "languagetool"
|
|
10
|
+
|
|
11
|
+
module SimpleEnglish
|
|
12
|
+
class Install
|
|
13
|
+
class SetupError < StandardError
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
attr_reader :cache_dir, :java
|
|
17
|
+
|
|
18
|
+
def initialize(cache_dir:, java: nil)
|
|
19
|
+
@cache_dir = cache_dir
|
|
20
|
+
@java = java
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The one place the environment is read. SE_JAVA wins outright,
|
|
24
|
+
# because the user asserted it and nobody re-probes an override.
|
|
25
|
+
# Otherwise the code probes the PATH candidate by running it,
|
|
26
|
+
# because a file can exist and still be the macOS stub that
|
|
27
|
+
# reports no runtime. The last resort is the Homebrew location,
|
|
28
|
+
# which sits outside PATH.
|
|
29
|
+
def self.from_env(env = ENV)
|
|
30
|
+
cache = File.expand_path(env.fetch(LanguageTool::CACHE_DIR_ENV) do
|
|
31
|
+
File.join(Dir.home, ".cache", "se")
|
|
32
|
+
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)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Runs the candidate under the given PATH, isolated from this
|
|
40
|
+
# process's own PATH so tests can probe a fake environment.
|
|
41
|
+
def self.probe?(candidate, env)
|
|
42
|
+
system({"PATH" => env.fetch("PATH", "")}, candidate, "-version",
|
|
43
|
+
out: File::NULL, err: File::NULL)
|
|
44
|
+
end
|
|
45
|
+
private_class_method :probe?
|
|
46
|
+
|
|
47
|
+
def java?
|
|
48
|
+
!java.nil?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def java!
|
|
52
|
+
return java if java?
|
|
53
|
+
raise SetupError,
|
|
54
|
+
"java not found. Install a JRE (on macOS: brew install openjdk), " \
|
|
55
|
+
"or set SE_JAVA to your java binary."
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def lt_dir
|
|
59
|
+
File.join(cache_dir, "LanguageTool-#{LanguageTool::LT_VERSION}")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def commandline_jar
|
|
63
|
+
File.join(lt_dir, "languagetool-commandline.jar")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def server_jar
|
|
67
|
+
File.join(lt_dir, "languagetool-server.jar")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def setup_error
|
|
71
|
+
"LanguageTool #{LanguageTool::LT_VERSION} not found at #{server_jar}. " \
|
|
72
|
+
"This gem pins that version. Run `se setup`."
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
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/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 the install directory, or nil after warning why. Pure Ruby
|
|
26
|
+
# throughout: the download uses Net::HTTP and the unpack uses rubyzip,
|
|
27
|
+
# so setup needs no curl or unzip on the machine.
|
|
28
|
+
def install(dir)
|
|
29
|
+
require "fileutils"
|
|
30
|
+
dest = File.join(dir, "LanguageTool-#{LT_VERSION}")
|
|
31
|
+
if File.exist?(File.join(dest, "languagetool-commandline.jar"))
|
|
32
|
+
remove_stale_versions(dir, dest)
|
|
33
|
+
return dest
|
|
34
|
+
end
|
|
35
|
+
zip = File.join(dir, "LanguageTool-#{LT_VERSION}.zip")
|
|
36
|
+
FileUtils.mkdir_p(dir)
|
|
37
|
+
return nil unless download(download_url, zip)
|
|
38
|
+
return nil unless extract(zip, dir)
|
|
39
|
+
File.delete(zip)
|
|
40
|
+
remove_stale_versions(dir, dest)
|
|
41
|
+
dest
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def download_url
|
|
45
|
+
"https://languagetool.org/download/LanguageTool-#{LT_VERSION}.zip"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Streams URL to TO and follows redirects. Warns and returns false
|
|
49
|
+
# on any transport or HTTP failure, so callers never see a stack
|
|
50
|
+
# trace.
|
|
51
|
+
def download(url, to, redirects: 3)
|
|
52
|
+
require "net/http"
|
|
53
|
+
uri = URI(url)
|
|
54
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
55
|
+
open_timeout: 10, read_timeout: 60) do |http|
|
|
56
|
+
http.request(Net::HTTP::Get.new(uri)) do |response|
|
|
57
|
+
case response
|
|
58
|
+
when Net::HTTPRedirection
|
|
59
|
+
return false if redirects.zero?
|
|
60
|
+
return download(response["location"], to, redirects: redirects - 1)
|
|
61
|
+
when Net::HTTPSuccess
|
|
62
|
+
File.binwrite(to, "")
|
|
63
|
+
File.open(to, "wb") do |file|
|
|
64
|
+
response.read_body { |chunk| file.write(chunk) }
|
|
65
|
+
end
|
|
66
|
+
return true
|
|
67
|
+
else
|
|
68
|
+
warn "error: download failed: HTTP #{response.code} from #{url}"
|
|
69
|
+
return false
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
rescue SystemCallError, SocketError, Timeout::Error,
|
|
74
|
+
OpenSSL::SSL::SSLError, Net::ProtocolError => e
|
|
75
|
+
warn "error: download failed: #{e.class}: #{e.message}"
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Unpacks ZIP into DIR and refuses entries that try to escape it.
|
|
80
|
+
# Warns and returns false on any failure.
|
|
81
|
+
def extract(zip, dir)
|
|
82
|
+
require "zip"
|
|
83
|
+
Zip::File.open(zip) do |archive|
|
|
84
|
+
archive.each do |entry|
|
|
85
|
+
unless safe_entry_target(dir, entry.name)
|
|
86
|
+
warn "error: zip entry escapes the install dir: #{entry.name}"
|
|
87
|
+
return false
|
|
88
|
+
end
|
|
89
|
+
entry.extract(entry.name, destination_directory: dir,
|
|
90
|
+
create_parent_directories: true)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
true
|
|
94
|
+
rescue SystemCallError, Zip::Error => e
|
|
95
|
+
warn "error: unpack failed: #{e.class}: #{e.message}"
|
|
96
|
+
false
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The path to write zip entry NAME into, or nil when NAME escapes
|
|
100
|
+
# DIR. A download is untrusted input: this is the zip-slip
|
|
101
|
+
# guard.
|
|
102
|
+
def safe_entry_target(dir, name)
|
|
103
|
+
target = File.expand_path(File.join(dir, name))
|
|
104
|
+
base = File.expand_path(dir) + File::SEPARATOR
|
|
105
|
+
target.start_with?(base) ? target : nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# One gem version pins one LanguageTool version. Older dirs are
|
|
109
|
+
# hundreds of MB each, so delete them on every setup run and keep
|
|
110
|
+
# only the current version (and any zips: they are re-deleted or
|
|
111
|
+
# re-extracted by their own flow).
|
|
112
|
+
def remove_stale_versions(dir, keep)
|
|
113
|
+
require "fileutils"
|
|
114
|
+
Dir.glob(File.join(dir, "LanguageTool-*")).each do |entry|
|
|
115
|
+
FileUtils.rm_rf(entry) if File.directory?(entry) && entry != keep
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Proves the installation works end to end: java runs the
|
|
120
|
+
# commandline jar and the custom rules load. The command exits
|
|
121
|
+
# nonzero on a broken java, a corrupt jar, or invalid rules XML,
|
|
122
|
+
# so setup can report a real verdict instead of "files exist".
|
|
123
|
+
# Returns a boolean, never raises or warns: the caller owns the
|
|
124
|
+
# message.
|
|
125
|
+
def smoke(install)
|
|
126
|
+
rule = rule_ids.first
|
|
127
|
+
return false if rule.nil?
|
|
128
|
+
require "tmpdir"
|
|
129
|
+
Dir.mktmpdir do |dir|
|
|
130
|
+
text = File.join(dir, "smoke.txt")
|
|
131
|
+
File.write(text, "Check this sentence.\n")
|
|
132
|
+
system(install.java!, "-jar", install.commandline_jar,
|
|
133
|
+
"--language", "en", "--rulefile", RULES_FILE,
|
|
134
|
+
"--enable", rule, "--enabledonly", text,
|
|
135
|
+
out: File::NULL, err: File::NULL) ? true : false
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def rule_ids
|
|
140
|
+
File.read(RULES_FILE).scan(/<rule(?:group)? id="(\w+)"/).flatten
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "paragraph"
|
|
4
|
+
|
|
5
|
+
# Markdown stripping and paragraph splitting. Block structure comes
|
|
6
|
+
# from the tree-sitter Markdown grammar shipped with the pack the
|
|
7
|
+
# extractor already uses. Inline fixes stay regular expressions.
|
|
8
|
+
# Stripping keeps the line count identical to the source.
|
|
9
|
+
|
|
10
|
+
module SimpleEnglish
|
|
11
|
+
module Markdown
|
|
12
|
+
SENTENCE_END = /[.!?]/
|
|
13
|
+
# Non-prose blocks: their lines never reach the counting rules or
|
|
14
|
+
# LanguageTool.
|
|
15
|
+
BLANKED_BLOCKS = %w[fenced_code_block indented_code_block thematic_break].freeze
|
|
16
|
+
UNDERLINE = /\Asetext_h\d_underline\z/
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# Blank out code and other non-prose blocks and neutralize inline
|
|
21
|
+
# code and heading markers. The line count stays identical to the
|
|
22
|
+
# source.
|
|
23
|
+
def strip(text)
|
|
24
|
+
lines = text.lines.map(&:chomp)
|
|
25
|
+
blank_rows(text).each { |row| lines[row] = "" }
|
|
26
|
+
lines.map { |line| strip_line(line) }.join("\n")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def strip_line(line)
|
|
30
|
+
line.gsub(/`[^`]*`/, "X").sub(/\A\#{1,6} /, "")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# A vertical list is not one paragraph: each list item is its own.
|
|
34
|
+
# Paragraph boundaries come from the tree-sitter Markdown grammar,
|
|
35
|
+
# so lazy continuations and interrupted lists match the spec
|
|
36
|
+
# instead of line-shape heuristics.
|
|
37
|
+
def paragraphs(text)
|
|
38
|
+
root = parse(text)
|
|
39
|
+
nodes = []
|
|
40
|
+
each_node(root) { |node| nodes << node if node.kind == "paragraph" }
|
|
41
|
+
nodes.sort_by!(&:start_byte)
|
|
42
|
+
nodes.filter_map do |node|
|
|
43
|
+
body = text.byteslice(node.start_byte, node.end_byte - node.start_byte)
|
|
44
|
+
lines = body.lines.map(&:strip).reject(&:empty?)
|
|
45
|
+
next if lines.empty?
|
|
46
|
+
Paragraph.new(lines: lines, start_line: node.start_position.row + 1,
|
|
47
|
+
procedural: inside_list_item?(node))
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def sentences_of(paragraph_body)
|
|
52
|
+
paragraph_body.split(SENTENCE_END).map(&:strip).reject(&:empty?)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The rows (0-based) a node fully occupies. Tree-sitter end
|
|
56
|
+
# positions are exclusive. A node ending at column 0 does not
|
|
57
|
+
# cover that row.
|
|
58
|
+
def node_rows(node)
|
|
59
|
+
last = node.end_position.column.zero? ? node.end_position.row - 1 : node.end_position.row
|
|
60
|
+
(node.start_position.row..last)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def blank_rows(text)
|
|
64
|
+
rows = []
|
|
65
|
+
each_node(parse(text)) do |node|
|
|
66
|
+
next if node.kind == "paragraph" # prose, not a block to blank
|
|
67
|
+
rows.concat(node_rows(node).to_a) if
|
|
68
|
+
BLANKED_BLOCKS.include?(node.kind) || UNDERLINE.match?(node.kind)
|
|
69
|
+
end
|
|
70
|
+
rows
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def each_node(root)
|
|
74
|
+
stack = [root]
|
|
75
|
+
until stack.empty?
|
|
76
|
+
node = stack.pop
|
|
77
|
+
yield node
|
|
78
|
+
# No paragraphs live inside code blocks. Skip their subtrees.
|
|
79
|
+
next if BLANKED_BLOCKS.include?(node.kind)
|
|
80
|
+
node.child_count.times { |i| stack.push(node.child(i)) }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def inside_list_item?(node)
|
|
85
|
+
return false unless node.parent
|
|
86
|
+
node.parent.kind == "list_item" || inside_list_item?(node.parent)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def parse(text)
|
|
90
|
+
require "tree_sitter_language_pack"
|
|
91
|
+
TreeSitterLanguagePack.get_parser("markdown").parse(text).root_node
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private_class_method :strip_line, :node_rows, :blank_rows, :each_node,
|
|
95
|
+
:inside_list_item?, :parse
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleEnglish
|
|
4
|
+
module Markdown
|
|
5
|
+
# A paragraph of prose: its lines, the first line's number in the
|
|
6
|
+
# source, and whether it starts with a list item (procedural text
|
|
7
|
+
# gets a tighter sentence limit).
|
|
8
|
+
Paragraph = Struct.new(:lines, :start_line, :procedural)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleEnglish
|
|
4
|
+
module Client
|
|
5
|
+
# The plain-text payload for /v2/check, duck-compatible with
|
|
6
|
+
# AnnotatedText::Result: #lt_params and #locate.
|
|
7
|
+
PlainText = Struct.new(:text) do
|
|
8
|
+
def lt_params
|
|
9
|
+
{"text" => text}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def locate(utf16_offset)
|
|
13
|
+
[Client.offset_to_line(text, utf16_offset), nil]
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleEnglish
|
|
4
|
+
module AnnotatedText
|
|
5
|
+
# The payload for code comments. Duck interface shared with
|
|
6
|
+
# Client::PlainText: #lt_params (the form data for LanguageTool)
|
|
7
|
+
# and #locate (a match offset back to file line and column).
|
|
8
|
+
Result = Struct.new(:source, :segments, :stream) do
|
|
9
|
+
def lt_params
|
|
10
|
+
{"data" => SimpleEnglish::AnnotatedText.data_json(self)}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def locate(utf16_offset)
|
|
14
|
+
SimpleEnglish::AnnotatedText.locate(self, utf16_offset)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleEnglish
|
|
4
|
+
module AnnotatedText
|
|
5
|
+
# One piece of the annotation stream: checkable comment text or
|
|
6
|
+
# uncheckable markup (markers, closers, gaps between comments).
|
|
7
|
+
# stream_start is the segment's offset into Result#stream.
|
|
8
|
+
Segment = Struct.new(:text, :content, :file_char, :stream_start,
|
|
9
|
+
:interpret_as)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
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.
|
|
5
|
+
# Failures raise typed errors (PortInUse, SetupError, InnerDied,
|
|
6
|
+
# InnerTimeout). bin/se owns turning them into warnings and exit codes.
|
|
7
|
+
|
|
8
|
+
require "fileutils"
|
|
9
|
+
require "net/http"
|
|
10
|
+
require "socket"
|
|
11
|
+
require "tempfile"
|
|
12
|
+
require "tmpdir"
|
|
13
|
+
|
|
14
|
+
require_relative "install"
|
|
15
|
+
|
|
16
|
+
module SimpleEnglish
|
|
17
|
+
module Server
|
|
18
|
+
class ServerError < StandardError; end
|
|
19
|
+
|
|
20
|
+
class PortInUse < ServerError
|
|
21
|
+
def initialize(port)
|
|
22
|
+
super("port #{port} is already in use.")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class InnerDied < ServerError; end
|
|
27
|
+
class InnerTimeout < ServerError; end
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
# LanguageTool's HTTP server has no --rulefile flag. Custom rules
|
|
32
|
+
# load from the classpath at this exact path.
|
|
33
|
+
def stage_rules(dir)
|
|
34
|
+
target = File.join(dir, "org/languagetool/rules/en/grammar_custom.xml")
|
|
35
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
36
|
+
FileUtils.cp(SimpleEnglish::LanguageTool::RULES_FILE, target)
|
|
37
|
+
target
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Probes both loopback families: on IPv6-first resolvers a "localhost"
|
|
41
|
+
# probe falls back to IPv4 and misses an IPv6 listener.
|
|
42
|
+
def assert_port_free(port)
|
|
43
|
+
["127.0.0.1", "::1"].each do |address|
|
|
44
|
+
TCPServer.new(address, port).close
|
|
45
|
+
rescue Errno::EADDRNOTAVAIL
|
|
46
|
+
next
|
|
47
|
+
end
|
|
48
|
+
rescue Errno::EADDRINUSE
|
|
49
|
+
raise PortInUse, port
|
|
50
|
+
rescue Errno::EACCES
|
|
51
|
+
raise ServerError, "port #{port} cannot be bound. Use a port above 1024."
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def start(port: Client::DEFAULT_PORT, install: Install.from_env, log: $stderr)
|
|
55
|
+
assert_port_free(port)
|
|
56
|
+
# The inner JVM lives on port + 1. Guard it too so an occupied
|
|
57
|
+
# inner port raises in milliseconds instead of timing out later.
|
|
58
|
+
assert_port_free(port + 1)
|
|
59
|
+
rules_dir = Dir.mktmpdir("se-rules")
|
|
60
|
+
stage_rules(rules_dir)
|
|
61
|
+
# The inner JVM's stderr goes to a file so failure messages can quote
|
|
62
|
+
# its first line. The dev log has a path. $stderr does not, so fall
|
|
63
|
+
# back to a temp file that Ruby unlinks when the process exits.
|
|
64
|
+
inner_log_path =
|
|
65
|
+
if log.respond_to?(:path)
|
|
66
|
+
log.path
|
|
67
|
+
else
|
|
68
|
+
inner_log = Tempfile.new("se-inner")
|
|
69
|
+
inner_log.close
|
|
70
|
+
inner_log.path
|
|
71
|
+
end
|
|
72
|
+
inner = spawn_inner(install: install, port: port + 1, rules_dir: rules_dir,
|
|
73
|
+
log_path: inner_log_path)
|
|
74
|
+
# TCPServer.new binds synchronously: construction means ready, so
|
|
75
|
+
# callers need no readiness polling. Closing the socket makes the
|
|
76
|
+
# serving loop's select raise and the loop exit.
|
|
77
|
+
server = TCPServer.new(port)
|
|
78
|
+
inner_died = false
|
|
79
|
+
# Closing the listening socket does not interrupt a syscall already
|
|
80
|
+
# blocked on the old fd, so the serving loop selects with a short
|
|
81
|
+
# timeout: the next IO.select on a closed socket raises IOError, so
|
|
82
|
+
# every shutdown path (trap, inner-death monitor) exits the loop
|
|
83
|
+
# within the timeout, on every platform.
|
|
84
|
+
stop_server = proc do
|
|
85
|
+
server.close
|
|
86
|
+
rescue IOError
|
|
87
|
+
# Already closed by another shutdown path.
|
|
88
|
+
end
|
|
89
|
+
# The monitor reaps the inner process, so nil it in the callback to
|
|
90
|
+
# keep the cleanup idempotent against the already-reaped pid.
|
|
91
|
+
monitor = monitor_inner(inner, on_death: proc do
|
|
92
|
+
inner = nil
|
|
93
|
+
inner_died = true
|
|
94
|
+
stop_server.call
|
|
95
|
+
end)
|
|
96
|
+
trap("INT") { stop_server.call }
|
|
97
|
+
trap("TERM") { stop_server.call }
|
|
98
|
+
(log.respond_to?(:puts) ? log : $stderr).puts "se listening on http://localhost:#{port}"
|
|
99
|
+
loop do
|
|
100
|
+
ready = IO.select([server], nil, nil, 0.5)
|
|
101
|
+
# Select timeout on an idle socket: loop back and select again.
|
|
102
|
+
next if ready.nil?
|
|
103
|
+
client = server.accept
|
|
104
|
+
Thread.new(client) { |c| HTTP.handle_client(c, port: port) }
|
|
105
|
+
rescue IOError, Errno::EBADF
|
|
106
|
+
# A trap or the inner-death monitor closed the listener. This
|
|
107
|
+
# happens during the select, or between select and accept.
|
|
108
|
+
break
|
|
109
|
+
end
|
|
110
|
+
monitor.kill if monitor.alive?
|
|
111
|
+
raise InnerDied, "inner LanguageTool server died. Rerun se serve." if inner_died
|
|
112
|
+
ensure
|
|
113
|
+
if inner
|
|
114
|
+
begin
|
|
115
|
+
Process.kill("TERM", inner)
|
|
116
|
+
Process.wait(inner)
|
|
117
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
118
|
+
# The monitor thread already reaped the inner process.
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
monitor&.kill if monitor&.alive?
|
|
122
|
+
FileUtils.remove_entry(rules_dir) if rules_dir
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Reaps the inner process and runs on_death (which stops the outer
|
|
126
|
+
# server) whenever it exits, so the daemon dies instead of serving
|
|
127
|
+
# without LanguageTool behind it.
|
|
128
|
+
def monitor_inner(pid, on_death:)
|
|
129
|
+
Thread.new do
|
|
130
|
+
Process.wait(pid)
|
|
131
|
+
on_death.call
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Boots the inner LanguageTool server and blocks until it answers
|
|
136
|
+
# /v2/check. Raises InnerDied when it dies during startup, and
|
|
137
|
+
# InnerTimeout when it never becomes ready (the child is killed and
|
|
138
|
+
# reaped first, so a failed boot leaks no JVM). Both messages quote
|
|
139
|
+
# the inner log's first line.
|
|
140
|
+
def spawn_inner(install:, port:, rules_dir:, log_path:)
|
|
141
|
+
raise Install::SetupError, install.setup_error unless File.exist?(install.server_jar)
|
|
142
|
+
java = install.java!
|
|
143
|
+
begin
|
|
144
|
+
pid = Process.spawn(java,
|
|
145
|
+
"-cp", [install.server_jar, rules_dir].join(File::PATH_SEPARATOR),
|
|
146
|
+
"org.languagetool.server.HTTPServer", "--port", port.to_s,
|
|
147
|
+
out: File::NULL, err: log_path)
|
|
148
|
+
rescue Errno::ENOENT
|
|
149
|
+
# The java lookup can resolve to a path that no longer exists.
|
|
150
|
+
# Quote what was resolved so the crash names its cause.
|
|
151
|
+
raise ServerError, "cannot exec #{java.inspect}."
|
|
152
|
+
end
|
|
153
|
+
ready = false
|
|
154
|
+
died = false
|
|
155
|
+
deadline = Time.now + SimpleEnglish::LanguageTool::TIMEOUT_SECONDS
|
|
156
|
+
until ready || died || Time.now > deadline
|
|
157
|
+
# A JVM that dies instantly must not be polled for the full timeout.
|
|
158
|
+
# WNOHANG reaps it here, so nothing else may wait on this pid after.
|
|
159
|
+
died = !!Process.wait(pid, Process::WNOHANG)
|
|
160
|
+
break if died
|
|
161
|
+
begin
|
|
162
|
+
Net::HTTP.post_form(URI("http://localhost:#{port}/v2/check"),
|
|
163
|
+
{"language" => "en", "text" => "a"})
|
|
164
|
+
ready = true
|
|
165
|
+
rescue SystemCallError
|
|
166
|
+
sleep 0.5
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
if died
|
|
170
|
+
raise InnerDied,
|
|
171
|
+
"inner LanguageTool server exited during startup. " \
|
|
172
|
+
"First log line: #{first_log_line(log_path)}"
|
|
173
|
+
end
|
|
174
|
+
unless ready
|
|
175
|
+
kill_and_reap(pid)
|
|
176
|
+
raise InnerTimeout,
|
|
177
|
+
"inner LanguageTool server did not start " \
|
|
178
|
+
"within #{SimpleEnglish::LanguageTool::TIMEOUT_SECONDS} seconds. " \
|
|
179
|
+
"First log line: #{first_log_line(log_path)}"
|
|
180
|
+
end
|
|
181
|
+
pid
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def kill_and_reap(pid)
|
|
185
|
+
Process.kill("TERM", pid)
|
|
186
|
+
Process.wait(pid)
|
|
187
|
+
rescue SystemCallError
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def first_log_line(path)
|
|
191
|
+
File.read(path).split("\n").first || "unknown"
|
|
192
|
+
rescue SystemCallError
|
|
193
|
+
"unknown"
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Inline suppressions. A line containing `se: ignore` (or
|
|
4
|
+
# `se: ignore=RULE1,RULE2`) suppresses findings reported on that
|
|
5
|
+
# same line. Pattern findings cite the line of the match, not the
|
|
6
|
+
# paragraph or comment start, so put the directive on the line the
|
|
7
|
+
# finding reports.
|
|
8
|
+
|
|
9
|
+
module SimpleEnglish
|
|
10
|
+
module Suppressions
|
|
11
|
+
DIRECTIVE = /se:\s*ignore\s*(?:=\s*(?<rules>[\w,]+))?/
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def filter(source, findings)
|
|
16
|
+
return findings if findings.empty?
|
|
17
|
+
blocked = directives(source)
|
|
18
|
+
return findings if blocked.empty?
|
|
19
|
+
findings.reject do |finding|
|
|
20
|
+
blocked.any? do |directive|
|
|
21
|
+
directive[:line] == finding.line &&
|
|
22
|
+
(directive[:rules].empty? || directive[:rules].include?(finding.rule))
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def directives(source)
|
|
28
|
+
source.lines.each_with_index.filter_map do |line, index|
|
|
29
|
+
next unless (match = line.match(DIRECTIVE))
|
|
30
|
+
rules = match[:rules] ? match[:rules].split(",").map(&:strip) : []
|
|
31
|
+
{line: index + 1, rules: rules}
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private_class_method :directives
|
|
36
|
+
end
|
|
37
|
+
end
|