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,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.2.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
@@ -56,10 +56,14 @@
56
56
  </rule>
57
57
 
58
58
  <rule id="SE_ING_AFTER_COMMA" name="No -ing after a comma">
59
- <pattern><token>,</token><token postag="VBG"/></pattern>
59
+ <pattern><token>,</token><token postag="VBG"><exception scope="next" regexp="yes">[,)]|and|or</exception></token></pattern>
60
60
  <message>Start a new sentence instead of the -ing phrase.</message>
61
61
  <example type="incorrect">The tool runs<marker>, making</marker> it easy.</example>
62
62
  <example type="correct">The tool runs. It is easy.</example>
63
+ <example type="correct">The walkthrough (accounts, VPC, secrets, troubleshooting) is in the guide.</example>
64
+ <example type="correct">The guide covers billing, troubleshooting, and support for each environment.</example>
65
+ <example type="correct">The guide covers billing, troubleshooting and support for each environment.</example>
66
+ <example type="correct">The guide covers billing, troubleshooting or support for each environment.</example>
63
67
  </rule>
64
68
 
65
69
  <rule id="SE_KEEP_THAT" name="make sure that">
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: simple_english
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - TonyCTHsu
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-28 00:00:00.000000000 Z
11
+ date: 2026-10-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: tree_sitter_language_pack
@@ -39,63 +39,63 @@ dependencies:
39
39
  - !ruby/object:Gem::Version
40
40
  version: '1.0'
41
41
  - !ruby/object:Gem::Dependency
42
- name: rubyzip
42
+ name: rake
43
43
  requirement: !ruby/object:Gem::Requirement
44
44
  requirements:
45
45
  - - "~>"
46
46
  - !ruby/object:Gem::Version
47
- version: '3.0'
48
- type: :runtime
47
+ version: '13.0'
48
+ type: :development
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
- version: '3.0'
54
+ version: '13.0'
55
55
  - !ruby/object:Gem::Dependency
56
- name: rake
56
+ name: minitest
57
57
  requirement: !ruby/object:Gem::Requirement
58
58
  requirements:
59
59
  - - "~>"
60
60
  - !ruby/object:Gem::Version
61
- version: '13.0'
61
+ version: '6.0'
62
62
  type: :development
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
- version: '13.0'
68
+ version: '6.0'
69
69
  - !ruby/object:Gem::Dependency
70
- name: minitest
70
+ name: minitest-mock
71
71
  requirement: !ruby/object:Gem::Requirement
72
72
  requirements:
73
73
  - - "~>"
74
74
  - !ruby/object:Gem::Version
75
- version: '6.0'
75
+ version: '5.27'
76
76
  type: :development
77
77
  prerelease: false
78
78
  version_requirements: !ruby/object:Gem::Requirement
79
79
  requirements:
80
80
  - - "~>"
81
81
  - !ruby/object:Gem::Version
82
- version: '6.0'
82
+ version: '5.27'
83
83
  - !ruby/object:Gem::Dependency
84
- name: minitest-mock
84
+ name: rubyzip
85
85
  requirement: !ruby/object:Gem::Requirement
86
86
  requirements:
87
87
  - - "~>"
88
88
  - !ruby/object:Gem::Version
89
- version: '5.27'
89
+ version: '3.0'
90
90
  type: :development
91
91
  prerelease: false
92
92
  version_requirements: !ruby/object:Gem::Requirement
93
93
  requirements:
94
94
  - - "~>"
95
95
  - !ruby/object:Gem::Version
96
- version: '5.27'
96
+ version: '3.0'
97
97
  - !ruby/object:Gem::Dependency
98
- name: json
98
+ name: rexml
99
99
  requirement: !ruby/object:Gem::Requirement
100
100
  requirements:
101
101
  - - ">="
@@ -109,7 +109,7 @@ dependencies:
109
109
  - !ruby/object:Gem::Version
110
110
  version: '0'
111
111
  - !ruby/object:Gem::Dependency
112
- name: rexml
112
+ name: json
113
113
  requirement: !ruby/object:Gem::Requirement
114
114
  requirements:
115
115
  - - ">="
@@ -135,31 +135,37 @@ files:
135
135
  - bin/se
136
136
  - docs/RULES.md
137
137
  - lib/simple_english.rb
138
- - lib/simple_english/annotated_text.rb
139
138
  - lib/simple_english/cli.rb
140
- - lib/simple_english/client.rb
141
- - lib/simple_english/config.rb
142
- - lib/simple_english/counts.rb
143
- - lib/simple_english/engine.rb
144
- - lib/simple_english/extractor.rb
145
- - lib/simple_english/finding.rb
146
- - lib/simple_english/http.rb
147
- - lib/simple_english/install.rb
148
- - lib/simple_english/languagetool.rb
149
- - lib/simple_english/markdown.rb
150
- - lib/simple_english/paragraph.rb
151
- - lib/simple_english/plain_text.rb
152
- - lib/simple_english/result.rb
153
- - lib/simple_english/segment.rb
154
- - lib/simple_english/server.rb
155
- - lib/simple_english/span.rb
156
- - lib/simple_english/suppressions.rb
139
+ - lib/simple_english/client/daemon.rb
140
+ - lib/simple_english/client/language_tool.rb
141
+ - lib/simple_english/daemon/engine.rb
142
+ - lib/simple_english/daemon/http.rb
143
+ - lib/simple_english/daemon/server.rb
144
+ - lib/simple_english/lint/annotated_text.rb
145
+ - lib/simple_english/lint/counts.rb
146
+ - lib/simple_english/lint/extractor.rb
147
+ - lib/simple_english/lint/finding.rb
148
+ - lib/simple_english/lint/lint_plan.rb
149
+ - lib/simple_english/lint/markdown.rb
150
+ - lib/simple_english/lint/paragraph.rb
151
+ - lib/simple_english/lint/plain_text.rb
152
+ - lib/simple_english/lint/result.rb
153
+ - lib/simple_english/lint/segment.rb
154
+ - lib/simple_english/lint/span.rb
155
+ - lib/simple_english/lint/suppressions.rb
156
+ - lib/simple_english/setup/config.rb
157
+ - lib/simple_english/setup/fingerprint.rb
158
+ - lib/simple_english/setup/install.rb
159
+ - lib/simple_english/setup/languagetool.rb
157
160
  - lib/simple_english/version.rb
158
161
  - rules/simple-english.xml
159
162
  homepage: https://github.com/TonyCTHsu/simple-english
160
163
  licenses:
161
164
  - MIT
162
- metadata: {}
165
+ metadata:
166
+ homepage_uri: https://github.com/TonyCTHsu/simple-english
167
+ source_code_uri: https://github.com/TonyCTHsu/simple-english
168
+ changelog_uri: https://github.com/TonyCTHsu/simple-english/blob/v0.4.0/CHANGELOG.md
163
169
  post_install_message:
164
170
  rdoc_options: []
165
171
  require_paths:
@@ -1,165 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # HTTP client for the LanguageTool server and the se daemon.
4
-
5
- require "json"
6
- require "net/http"
7
- require "rbconfig"
8
- require "uri"
9
-
10
- require_relative "plain_text"
11
- require_relative "languagetool"
12
-
13
- module SimpleEnglish
14
- module Client
15
- module_function
16
-
17
- # LanguageTool reports offsets in Java UTF-16 code units.
18
- # Astral characters (emoji) count as two. Count them so a
19
- # newline comparison cannot drift past a boundary. A match
20
- # starting at the newline itself belongs to the next line.
21
- def offset_to_line(text, offset)
22
- line = 1
23
- units = 0
24
- text.each_char do |char|
25
- line += 1 if char == "\n" && units <= offset
26
- return line if units >= offset
27
- units += (char.ord > 0xFFFF) ? 2 : 1
28
- end
29
- line
30
- end
31
-
32
- # The 1-based column of a UTF-16 offset within its line. Astral
33
- # characters count as two units, like the match offset. A match
34
- # starting at the newline itself is column one of the next line.
35
- def offset_to_column(text, offset)
36
- column = 0
37
- units = 0
38
- text.each_char do |char|
39
- if char == "\n"
40
- return 1 if units >= offset
41
- column = 0
42
- units += 1
43
- next
44
- end
45
- return column + 1 if units >= offset
46
- units += (char.ord > 0xFFFF) ? 2 : 1
47
- column += 1
48
- end
49
- column + 1
50
- end
51
-
52
- DEFAULT_PORT = 8181
53
- REQUEST_TIMEOUT = 30
54
-
55
- def url
56
- ENV.fetch("SE_SERVER_URL") { "http://localhost:#{DEFAULT_PORT}" }
57
- end
58
-
59
- def post(uri, params, read_timeout: REQUEST_TIMEOUT)
60
- Net::HTTP.start(uri.host, uri.port, open_timeout: 5,
61
- read_timeout: read_timeout) do |http|
62
- request = Net::HTTP::Post.new(uri.request_uri)
63
- request.set_form_data(params)
64
- http.request(request)
65
- end
66
- end
67
-
68
- private_class_method :post
69
-
70
- # Pattern rules. `payload` is Markdown-stripped text (String) or an
71
- # AnnotatedText::Result (code comments): one payload interface,
72
- # #lt_params and #locate, either side of the daemon's LT request.
73
- def check(payload, base_url: url)
74
- payload = to_payload(payload)
75
- params = {"language" => "en",
76
- "enabledRules" => SimpleEnglish::LanguageTool.rule_ids.join(","),
77
- "enabledOnly" => "true"}.merge(payload.lt_params)
78
- response = post(URI("#{base_url}/v2/check"), params)
79
- parse_matches(JSON.parse(response.body).fetch("matches"), payload)
80
- end
81
-
82
- def parse_matches(matches, payload)
83
- payload = to_payload(payload)
84
- matches.map do |match|
85
- line, column = payload.locate(match.fetch("offset"))
86
- Finding.new(line: line, column: column,
87
- rule: match.fetch("rule").fetch("id"),
88
- message: with_context(match.fetch("message"), match))
89
- end
90
- end
91
-
92
- # Prefix the offending text, so a finding says what to change,
93
- # not only how. LanguageTool returns it in the match context.
94
- # Context offsets are Java UTF-16 code units, like the match
95
- # offset.
96
- def with_context(message, match)
97
- context = match["context"] or return message
98
- matched = context.fetch("text", "")[
99
- context.fetch("offset", 0).to_i, context.fetch("length", 0).to_i]
100
- matched.empty? ? message : "\"#{matched}\" - #{message}"
101
- end
102
-
103
- def to_payload(payload)
104
- payload.is_a?(String) ? PlainText.new(payload) : payload
105
- end
106
-
107
- private_class_method :to_payload, :with_context
108
-
109
- # Full lint via the se daemon. Raw Markdown in, or code
110
- # source with a language for the comment pipeline. nil when
111
- # unreachable or the response is unusable, so callers can fall
112
- # back without seeing a stack trace.
113
- def lint(text, base_url: url, language: nil)
114
- params = {"text" => text}
115
- params["language"] = language if language
116
- response = post(URI("#{base_url}/lint"), params)
117
- return nil unless response.is_a?(Net::HTTPSuccess)
118
- body = JSON.parse(response.body)
119
- return nil unless body.is_a?(Array)
120
- body.map do |hash|
121
- Finding.new(line: hash.fetch("line"), column: hash["column"],
122
- rule: hash.fetch("rule"), message: hash.fetch("message"))
123
- end
124
- rescue SystemCallError, SocketError, Timeout::Error,
125
- JSON::ParserError, TypeError
126
- nil
127
- end
128
-
129
- def up?(base_url: url)
130
- uri = URI(base_url)
131
- Net::HTTP.start(uri.host, uri.port, open_timeout: 1,
132
- read_timeout: 2) { |http| http.head("/") }
133
- true
134
- rescue Errno::ECONNREFUSED, SocketError, Timeout::Error
135
- false
136
- end
137
-
138
- # True when the daemon answers. Starts it when we own the default
139
- # URL. A custom SE_SERVER_URL belongs to someone else, so
140
- # never spawn against it. It returns false when unusable. The
141
- # caller owns the exit status. Diagnostics go to stderr here, where the cause
142
- # is known.
143
- def ensure_up(install: SimpleEnglish::Install.from_env)
144
- return true if up?
145
- if ENV["SE_SERVER_URL"]
146
- warn "error: SE_SERVER_URL is set but #{url} does not answer."
147
- return false
148
- end
149
- unless File.exist?(install.server_jar)
150
- warn install.setup_error
151
- return false
152
- end
153
- warn "se: daemon not running; starting it (first lint takes ~15s)..."
154
- bin = File.expand_path("../../bin/se", __dir__)
155
- Process.spawn(RbConfig.ruby, bin, "serve", out: File::NULL, err: File::NULL)
156
- deadline = Time.now + 90
157
- until Time.now > deadline
158
- return true if up?
159
- sleep 0.5
160
- end
161
- warn "error: se daemon did not come up. Run `se serve` and read its output."
162
- false
163
- end
164
- end
165
- end