simple_english 0.4.2 → 0.13.1

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.
@@ -141,8 +141,8 @@ module SimpleEnglish
141
141
  # Preflight everything the spawn needs: a missing prerequisite
142
142
  # must fail fast with the real blocker named, not spawn a
143
143
  # doomed child and wait out 90 s.
144
- unless File.exist?(install.server_jar) && install.java?
145
- warn(install.java? ? install.setup_error : install.java_message)
144
+ unless install.executable?
145
+ warn install.setup_error
146
146
  return false
147
147
  end
148
148
  # One boot at a time: a concurrent lint that also found the
@@ -153,7 +153,7 @@ module SimpleEnglish
153
153
  with_spawn_lock do
154
154
  daemon = info
155
155
  unless daemon
156
- warn "se: daemon not running; starting it (first lint takes ~15s)..."
156
+ warn "se: daemon not running; starting it (first lint may take a few seconds)..."
157
157
  spawn_daemon
158
158
  daemon = wait_for { info }
159
159
  end
@@ -185,10 +185,9 @@ module SimpleEnglish
185
185
  "(#{VERSION}). #{fix} Linting against it meanwhile."
186
186
  end
187
187
 
188
- # A per-user lock keyed by the port, independent of the
189
- # installation cache: daemon ownership is scoped to the port,
190
- # so two lints with different SE_CACHE_DIR values still
191
- # coordinate. flock releases when the block ends, even on
188
+ # A per-user lock keyed by the port: daemon ownership is scoped
189
+ # to the port, so concurrent lints coordinate across installs.
190
+ # flock releases when the block ends, even on
192
191
  # failure.
193
192
  def with_spawn_lock
194
193
  dir = File.join(Dir.home, ".cache", "simple_english")
@@ -2,7 +2,7 @@
2
2
 
3
3
  # The LanguageTool wire protocol: form posts to /v2/check, and
4
4
  # matches mapped back to findings. Runs daemon-side (Engine calls it
5
- # against the inner JVM) and in tests. The se daemon client - probe,
5
+ # against the inner native server) and in tests. The se daemon client - probe,
6
6
  # trust, boot - lives in client/daemon.rb.
7
7
  #
8
8
  # The LanguageTool namespace is shared with setup/languagetool.rb,
@@ -14,6 +14,7 @@ require "net/http"
14
14
  require "uri"
15
15
 
16
16
  require_relative "../lint/plain_text"
17
+ require_relative "../lint/utf16"
17
18
 
18
19
  module SimpleEnglish
19
20
  module LanguageTool
@@ -71,25 +72,16 @@ module SimpleEnglish
71
72
  end
72
73
 
73
74
  def utf16_slice(text, offset, length)
74
- first = utf16_index(text, offset)
75
- last = utf16_index(text, offset + length)
75
+ first = UTF16.index(text, offset)
76
+ last = UTF16.index(text, offset + length)
76
77
  text[first...last]
77
78
  end
78
79
 
79
- def utf16_index(text, offset)
80
- units = 0
81
- text.each_char.with_index do |char, index|
82
- return index if units >= offset
83
- units += (char.ord > 0xFFFF) ? 2 : 1
84
- end
85
- text.length
86
- end
87
-
88
80
  def to_payload(payload)
89
81
  payload.is_a?(String) ? PlainText.new(payload) : payload
90
82
  end
91
83
 
92
- private_class_method :to_payload, :with_context, :utf16_slice, :utf16_index
84
+ private_class_method :to_payload, :with_context, :utf16_slice
93
85
  end
94
86
  end
95
87
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # The complete lint engine, daemon-side. Never call SimpleEnglish.lint_text
3
+ # The complete lint engine, daemon-side. Never call SimpleEnglish.lint
4
4
  # here: that call probes the daemon and recurses into the server.
5
5
  # The engine reads nothing from disk: the caller captures the enabled
6
6
  # rule IDs once at daemon boot (Server.start) and passes them in.
@@ -6,11 +6,9 @@
6
6
  # Failures raise typed errors (PortInUse, SetupError, InnerDied,
7
7
  # InnerTimeout). bin/se owns turning them into warnings and exit codes.
8
8
 
9
- require "fileutils"
10
9
  require "net/http"
11
10
  require "socket"
12
11
  require "tempfile"
13
- require "tmpdir"
14
12
 
15
13
  require_relative "../setup/install"
16
14
  require_relative "../client/daemon"
@@ -28,19 +26,11 @@ module SimpleEnglish
28
26
  end
29
27
 
30
28
  class InnerDied < ServerError; end
29
+
31
30
  class InnerTimeout < ServerError; end
32
31
 
33
32
  module_function
34
33
 
35
- # LanguageTool's HTTP server has no --rulefile flag. Custom rules
36
- # load from the classpath at this exact path.
37
- def stage_rules(dir)
38
- target = File.join(dir, "org/languagetool/rules/en/grammar_custom.xml")
39
- FileUtils.mkdir_p(File.dirname(target))
40
- FileUtils.cp(SimpleEnglish::LanguageTool::RULES_FILE, target)
41
- target
42
- end
43
-
44
34
  # Probes both loopback families: on IPv6-first resolvers a "localhost"
45
35
  # probe falls back to IPv4 and misses an IPv6 listener.
46
36
  def assert_port_free(port)
@@ -73,7 +63,7 @@ module SimpleEnglish
73
63
  daemon = Client.info(base_url: "http://localhost:#{port}")
74
64
  return unless daemon.is_a?(Hash)
75
65
  stop(daemon["pid"])
76
- # The outer listener closes first. The inner JVM takes seconds
66
+ # The outer listener closes first. The inner server takes
77
67
  # longer. Both ports must free, or the next boot hits the inner
78
68
  # port and dies.
79
69
  deadline = Time.now + 10
@@ -82,7 +72,7 @@ module SimpleEnglish
82
72
  port_free?(port + 1)
83
73
  end
84
74
 
85
- # Graceful only. SIGKILL skips the TERM trap and the inner JVM
75
+ # Graceful only. SIGKILL skips the TERM trap and the inner server
86
76
  # teardown. The orphan still holds port + 1 and breaks the next
87
77
  # boot. The pid comes off the wire, so only a positive integer is
88
78
  # TERMed: 0 signals the caller's own process group, a negative
@@ -98,9 +88,7 @@ module SimpleEnglish
98
88
  # takeover stops the old daemon: a reload that cannot boot must
99
89
  # leave the running daemon alive.
100
90
  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
91
+ install.executable!
104
92
  end
105
93
  private_class_method :preflight_install
106
94
 
@@ -122,7 +110,7 @@ module SimpleEnglish
122
110
  args = ["--port", port.to_s]
123
111
  args.concat(["--dev-log", dev_log]) if dev_log
124
112
  pid = Client.spawn_daemon(*args)
125
- # The child gives its inner JVM TIMEOUT_SECONDS to boot, and
113
+ # The child gives its inner server TIMEOUT_SECONDS to boot, and
126
114
  # the handshake answers only after that: wait the child's own
127
115
  # budget, not the default 90 s, or a slow boot reads as
128
116
  # failure while the child is still coming up.
@@ -145,23 +133,19 @@ module SimpleEnglish
145
133
  end
146
134
 
147
135
  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
136
  enabled_rules = SimpleEnglish::LanguageTool.rule_ids
153
137
  daemon_info = {"version" => VERSION, "pid" => Process.pid,
154
138
  "gem_digest" => Fingerprint.gem,
155
- "rules_digest" => Fingerprint.sha(File.read(staged))}
139
+ "rules_digest" => Fingerprint.sha(File.read(LanguageTool::RULES_FILE))}
156
140
  # Preflight everything the replacement needs before the
157
141
  # takeover stops the old daemon.
158
142
  preflight_install(install)
159
143
  takeover(port)
160
144
  assert_port_free(port)
161
- # The inner JVM lives on port + 1. Guard it too so an occupied
145
+ # The inner native server lives on port + 1. Guard it too so an occupied
162
146
  # inner port raises in milliseconds instead of timing out later.
163
147
  assert_port_free(port + 1)
164
- # The inner JVM's stderr goes to a file so failure messages can quote
148
+ # The inner native server's stderr goes to a file so failure messages can quote
165
149
  # its first line. Only an explicitly opened dev log (a File) is reused
166
150
  # for that. $stderr reports path "<STDERR>", so it creates a file
167
151
  # by that name in the CWD. Anything else falls back to a temp file
@@ -174,7 +158,7 @@ module SimpleEnglish
174
158
  inner_log.close
175
159
  inner_log.path
176
160
  end
177
- inner = spawn_inner(install: install, port: port + 1, rules_dir: rules_dir,
161
+ inner = spawn_inner(install: install, port: port + 1,
178
162
  log_path: inner_log_path)
179
163
  # TCPServer.new binds synchronously: construction means ready, so
180
164
  # callers need no readiness polling. Closing the socket makes the
@@ -216,7 +200,7 @@ module SimpleEnglish
216
200
  break
217
201
  end
218
202
  monitor.kill if monitor.alive?
219
- raise InnerDied, "inner LanguageTool server died. Rerun se serve." if inner_died
203
+ raise InnerDied, "lint engine died. Rerun se serve." if inner_died
220
204
  ensure
221
205
  if inner
222
206
  begin
@@ -227,12 +211,11 @@ module SimpleEnglish
227
211
  end
228
212
  end
229
213
  monitor&.kill if monitor&.alive?
230
- FileUtils.remove_entry(rules_dir) if rules_dir
231
214
  end
232
215
 
233
216
  # Reaps the inner process and runs on_death (which stops the outer
234
217
  # server) whenever it exits, so the daemon dies instead of serving
235
- # without LanguageTool behind it.
218
+ # without the lint engine behind it.
236
219
  def monitor_inner(pid, on_death:)
237
220
  Thread.new do
238
221
  Process.wait(pid)
@@ -240,35 +223,33 @@ module SimpleEnglish
240
223
  end
241
224
  end
242
225
 
243
- # Boots the inner LanguageTool server and blocks until it answers
226
+ # Boots the inner native server and blocks until it answers
244
227
  # /v2/check. Raises InnerDied when it dies during startup, and
245
228
  # InnerTimeout when it never becomes ready (the child is killed and
246
- # reaped first, so a failed boot leaks no JVM). Both messages quote
229
+ # reaped first, so a failed boot leaks no process). Both messages quote
247
230
  # the inner log's first line.
248
- def spawn_inner(install:, port:, rules_dir:, log_path:)
249
- raise Install::SetupError, install.setup_error unless File.exist?(install.server_jar)
250
- java = install.java!
231
+ def spawn_inner(install:, port:, log_path:)
232
+ executable = install.executable!
251
233
  begin
252
- pid = Process.spawn(java,
253
- "-cp", [install.server_jar, rules_dir].join(File::PATH_SEPARATOR),
254
- "org.languagetool.server.HTTPServer", "--port", port.to_s,
234
+ pid = Process.spawn(executable, "--port", port.to_s,
255
235
  out: File::NULL, err: log_path)
256
236
  rescue Errno::ENOENT
257
- # The java lookup can resolve to a path that no longer exists.
258
- # Quote what was resolved so the crash names its cause.
259
- raise ServerError, "cannot exec #{java.inspect}."
237
+ raise ServerError, "cannot exec #{executable.inspect}."
260
238
  end
261
239
  ready = false
262
240
  died = false
263
241
  deadline = Time.now + SimpleEnglish::LanguageTool::TIMEOUT_SECONDS
264
242
  until ready || died || Time.now > deadline
265
- # A JVM that dies instantly must not be polled for the full timeout.
243
+ # A process that dies instantly must not be polled for the full timeout.
266
244
  # WNOHANG reaps it here, so nothing else may wait on this pid after.
267
245
  died = !!Process.wait(pid, Process::WNOHANG)
268
246
  break if died
269
247
  begin
270
248
  Net::HTTP.post_form(URI("http://localhost:#{port}/v2/check"),
271
- {"language" => "en", "text" => "a"})
249
+ {"language" => "en",
250
+ "enabledRules" => SimpleEnglish::LanguageTool.rule_ids.first,
251
+ "enabledOnly" => "true",
252
+ "text" => "a"})
272
253
  ready = true
273
254
  rescue SystemCallError
274
255
  sleep 0.5
@@ -276,13 +257,13 @@ module SimpleEnglish
276
257
  end
277
258
  if died
278
259
  raise InnerDied,
279
- "inner LanguageTool server exited during startup. " \
260
+ "lint engine exited during startup. " \
280
261
  "First log line: #{first_log_line(log_path)}"
281
262
  end
282
263
  unless ready
283
264
  kill_and_reap(pid)
284
265
  raise InnerTimeout,
285
- "inner LanguageTool server did not start " \
266
+ "lint engine did not start " \
286
267
  "within #{SimpleEnglish::LanguageTool::TIMEOUT_SECONDS} seconds. " \
287
268
  "First log line: #{first_log_line(log_path)}"
288
269
  end
@@ -9,6 +9,7 @@
9
9
  require_relative "extractor"
10
10
  require_relative "segment"
11
11
  require_relative "result"
12
+ require_relative "utf16"
12
13
 
13
14
  module SimpleEnglish
14
15
  module AnnotatedText
@@ -21,10 +22,12 @@ module SimpleEnglish
21
22
  def build(source, spans)
22
23
  segments = []
23
24
  cursor = 0 # byte offset into source
25
+ stream_start = 0 # character offset into the annotation stream
24
26
  emit = lambda do |text:, content:, file_char:, interpret_as: nil|
25
27
  next if content.empty?
26
28
  segments << Segment.new(text: text, content: content, file_char: file_char,
27
- stream_start: segments.sum { |s| s.content.length }, interpret_as: interpret_as)
29
+ stream_start: stream_start, interpret_as: interpret_as)
30
+ stream_start += content.length
28
31
  end
29
32
  spans = spans.sort_by(&:start_byte)
30
33
  spans.each do |span|
@@ -66,20 +69,20 @@ module SimpleEnglish
66
69
 
67
70
  # -> [line, column], both 1-based. Column in UTF-16 code units.
68
71
  def locate(result, utf16_offset)
69
- char = utf16_to_char(result.stream, utf16_offset)
72
+ char = UTF16.index(result.stream, utf16_offset)
70
73
  segment = result.segments.find do |s|
71
74
  char >= s.stream_start && char < s.stream_start + s.content.length
72
75
  end || result.segments.last
73
76
  file_char = segment.file_char + (char - segment.stream_start)
74
77
  starts = line_starts(result.source)
75
78
  line = starts.rindex { |start| start <= file_char } + 1
76
- [line, utf16_column(result.source, starts[line - 1], file_char)]
79
+ [line, UTF16.column(result.source, starts[line - 1], file_char)]
77
80
  end
78
81
 
79
82
  def split(comment)
80
83
  marker = comment[MARKER] || ""
81
84
  body = comment[marker.length..] || comment
82
- closer = body[CLOSER] || ""
85
+ closer = body[CLOSER, 1] || ""
83
86
  body = body[0, body.length - closer.length] unless closer.empty?
84
87
  [marker, body, closer]
85
88
  end
@@ -96,22 +99,6 @@ module SimpleEnglish
96
99
  starts
97
100
  end
98
101
 
99
- def utf16_to_char(stream, offset)
100
- units = 0
101
- stream.each_char.with_index do |char, index|
102
- return index if units >= offset
103
- units += (char.ord > 0xFFFF) ? 2 : 1
104
- end
105
- stream.length
106
- end
107
-
108
- def utf16_column(source, from, to)
109
- units = 1
110
- source[from...to].each_char { |c| units += (c.ord > 0xFFFF) ? 2 : 1 }
111
- units
112
- end
113
-
114
- private_class_method :split, :char_index, :line_starts,
115
- :utf16_to_char, :utf16_column
102
+ private_class_method :split, :char_index, :line_starts
116
103
  end
117
104
  end
@@ -25,16 +25,60 @@ module SimpleEnglish
25
25
  ".cc" => "cpp",
26
26
  ".cxx" => "cpp",
27
27
  ".hpp" => "cpp",
28
- ".h" => "cpp"
28
+ ".h" => "cpp",
29
+ ".c" => "c",
30
+ ".m" => "objc",
31
+ ".php" => "php",
32
+ ".swift" => "swift",
33
+ ".scala" => "scala",
34
+ ".ex" => "elixir",
35
+ ".exs" => "elixir",
36
+ ".lua" => "lua",
37
+ ".dart" => "dart",
38
+ ".pl" => "perl",
39
+ ".pm" => "perl",
40
+ ".r" => "r",
41
+ ".sql" => "sql",
42
+ ".hs" => "haskell",
43
+ ".zig" => "zig",
44
+ ".toml" => "toml",
45
+ ".ini" => "ini",
46
+ ".mk" => "makefile",
47
+ ".html" => "html",
48
+ ".htm" => "html",
49
+ ".xml" => "xml"
29
50
  }.freeze
30
51
 
52
+ # Dockerfile and Makefile carry no extension, and the variants
53
+ # keep the language: Dockerfile.dev, Makefile.dev.
54
+ NAME_LANGUAGES = {
55
+ "Dockerfile" => "dockerfile",
56
+ "Makefile" => "makefile",
57
+ "makefile" => "makefile",
58
+ "GNUmakefile" => "makefile"
59
+ }.freeze
60
+
61
+ PROSE_EXTENSIONS = [".md"].freeze
62
+
31
63
  module_function
32
64
 
33
65
  # nil for files we do not lint.
34
66
  def language_for(path)
67
+ name = File.basename(path)
68
+ return NAME_LANGUAGES[name] if NAME_LANGUAGES[name]
69
+ return "dockerfile" if name.start_with?("Dockerfile.")
70
+ return "makefile" if name.start_with?("Makefile.")
35
71
  EXTENSION_LANGUAGES[File.extname(path)]
36
72
  end
37
73
 
74
+ # true when the path is a file this linter owns: Markdown, stdin,
75
+ # or a supported code language. Everything else is skipped, so a
76
+ # single explicit path behaves like directory expansion (cli.rb).
77
+ def lintable?(path)
78
+ path == "-" || PROSE_EXTENSIONS.include?(File.extname(path)) ||
79
+ !language_for(path).nil?
80
+ end
81
+
38
82
  def comment_spans(source, language)
39
83
  require "tree_sitter_language_pack"
40
84
  root = TreeSitterLanguagePack.get_parser(language).parse(source).root_node
@@ -51,8 +95,11 @@ module SimpleEnglish
51
95
  spans.sort_by!(&:start_byte)
52
96
  end
53
97
 
98
+ # The xml grammar names the node "Comment", so the kind check
99
+ # reads case-insensitive.
54
100
  def comment?(node)
55
- node.kind.end_with?("comment") && !node.kind.start_with?("non")
101
+ kind = node.kind.downcase
102
+ kind.end_with?("comment") && !kind.start_with?("non")
56
103
  end
57
104
 
58
105
  private_class_method :comment?
@@ -5,5 +5,20 @@
5
5
  # daemon can have a start column without an end position.
6
6
 
7
7
  module SimpleEnglish
8
- Finding = Struct.new(:line, :column, :end_line, :end_column, :rule, :message)
8
+ Finding = Struct.new(:line, :column, :end_line, :end_column, :rule, :message) do
9
+ # One finding as a text line: `path:line:col-end: [rule] message`.
10
+ # The CLI text output, the MCP tool, and the hook feedback all share
11
+ # this format. Keep them in step by rendering through this method.
12
+ def to_line(path)
13
+ location = "#{path}:#{line}"
14
+ if column
15
+ location += ":#{column}"
16
+ if end_line && end_column
17
+ finish = (end_line == line) ? end_column : "#{end_line}:#{end_column}"
18
+ location += "-#{finish}"
19
+ end
20
+ end
21
+ "#{location}: [#{rule}] #{message}"
22
+ end
23
+ end
9
24
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # One decision, two callers: the CLI (SimpleEnglish.lint_file) and
3
+ # One decision, two callers: the CLI (SimpleEnglish.lint) and
4
4
  # the daemon (Engine.lint) must agree on what kind of lint an input
5
5
  # is. The CLI decides to skip comment-free files without a boot.
6
6
  # The daemon decides again on its side of the wire. Both call this
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleEnglish
4
+ module Linter
5
+ module_function
6
+
7
+ def lint_file(path, config: Config.load)
8
+ return [] if Config.ignore?(config, path) || !Extractor.lintable?(path)
9
+ lint_text(File.read(path, encoding: "UTF-8"), language: Extractor.language_for(path), config: config, path: path)
10
+ end
11
+
12
+ def lint_text(text, language: nil, config: Config.load, path: nil)
13
+ findings = SimpleEnglish.lint(text, language: language)
14
+ findings && Config.filter(config, findings, path: path)
15
+ end
16
+ end
17
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "paragraph"
4
+ require_relative "utf16"
4
5
 
5
6
  # Markdown stripping and paragraph splitting. Block structure comes
6
7
  # from the tree-sitter Markdown grammar shipped with the pack the
@@ -42,16 +43,12 @@ module SimpleEnglish
42
43
  # Width-preserving replacements keep LanguageTool positions aligned
43
44
  # with the source.
44
45
  line
45
- .gsub(/`[^`]*`/) { |code| "X" * utf16_length(code) }
46
+ .gsub(/`[^`]*`/) { |code| "X" * UTF16.length(code) }
46
47
  .sub(/\A\#{1,6} /) { |marker| " " * marker.length }
47
48
  end
48
49
 
49
50
  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 }
51
+ " " * UTF16.length(text)
55
52
  end
56
53
 
57
54
  # A vertical list is not one paragraph: each list item is its own.
@@ -115,7 +112,7 @@ module SimpleEnglish
115
112
  TreeSitterLanguagePack.get_parser("markdown").parse(text).root_node
116
113
  end
117
114
 
118
- private_class_method :strip_line, :spaces_for, :utf16_length, :node_rows,
115
+ private_class_method :strip_line, :spaces_for, :node_rows,
119
116
  :blank_rows, :each_node, :inside_list_item?, :parse
120
117
  end
121
118
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ # UTF-16 code-unit arithmetic. LanguageTool is a Java service, and
4
+ # Java strings count UTF-16 code units: an astral-plane character
5
+ # counts as two. Every offset crossing the LT wire goes through
6
+ # here.
7
+
8
+ module SimpleEnglish
9
+ module UTF16
10
+ module_function
11
+
12
+ # The character index in text where the offsetth code unit
13
+ # falls.
14
+ def index(text, offset)
15
+ units = 0
16
+ text.each_char.with_index do |char, index|
17
+ return index if units >= offset
18
+ units += (char.ord > 0xFFFF) ? 2 : 1
19
+ end
20
+ text.length
21
+ end
22
+
23
+ # The code-unit length of text.
24
+ def length(text)
25
+ text.each_char.sum { |char| (char.ord > 0xFFFF) ? 2 : 1 }
26
+ end
27
+
28
+ # The 1-based code-unit column of the character at index to.
29
+ def column(source, from, to)
30
+ length(source[from...to]) + 1
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ # MCP JSON-RPC server for the lint engine. `respond` is pure: request
6
+ # Hash in, response Hash or nil (notifications) out. `run` drives it
7
+ # over stdio, one JSON-RPC message per line. Protocol facts:
8
+ # integrations/verification.md.
9
+
10
+ module SimpleEnglish
11
+ module ModelContextProtocol
12
+ SUPPORTED_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"].freeze
13
+ LATEST_VERSION = "2025-06-18"
14
+ SERVER_NAME = "simple-english"
15
+
16
+ module_function
17
+
18
+ def run(io: $stdin, out: $stdout, linter: Linter)
19
+ # A piped stdout buffers until 4 KB or EOF. An MCP client keeps
20
+ # stdin open while it waits for the answer, so an unflushed
21
+ # response never arrives and the client drops the server.
22
+ out.sync = true
23
+ while (line = io.gets)
24
+ line = line.strip
25
+ next if line.empty?
26
+ begin
27
+ request = JSON.parse(line)
28
+ rescue JSON::ParserError
29
+ out.puts JSON.generate(error(nil, -32700, "Parse error"))
30
+ next
31
+ end
32
+ next unless request.is_a?(Hash)
33
+ response =
34
+ begin
35
+ respond(request, linter: linter)
36
+ rescue => e
37
+ # A broken tool call (unreadable file, daemon hiccup) must not
38
+ # kill the loop: answer it, then serve the next line.
39
+ request.key?("id") ? error(request["id"], -32603, "lint failed: #{e.class}: #{e.message}") : nil
40
+ end
41
+ out.puts JSON.generate(response) if response
42
+ end
43
+ end
44
+
45
+ def respond(request, linter:)
46
+ return nil unless request.key?("id")
47
+ id = request.fetch("id")
48
+ method = request["method"]
49
+ return error(id, -32600, "Invalid Request") unless method.is_a?(String)
50
+ params = request["params"]
51
+ params = {} unless params.is_a?(Hash)
52
+ case method
53
+ when "initialize" then ok(id, initialize_result(params["protocolVersion"]))
54
+ when "ping" then ok(id, {})
55
+ when "tools/list" then ok(id, {"tools" => [lint_tool]})
56
+ when "tools/call" then tools_call(id, params, linter)
57
+ else error(id, -32601, "Method not found: #{method}")
58
+ end
59
+ end
60
+
61
+ def initialize_result(requested_version)
62
+ version = SUPPORTED_VERSIONS.include?(requested_version) ? requested_version : LATEST_VERSION
63
+ {
64
+ "protocolVersion" => version,
65
+ "capabilities" => {"tools" => {}},
66
+ "serverInfo" => {"name" => SERVER_NAME, "version" => SimpleEnglish::VERSION}
67
+ }
68
+ end
69
+
70
+ def lint_tool
71
+ {
72
+ "name" => "lint",
73
+ "description" =>
74
+ "Lint Markdown prose and code comments against plain-English rules " \
75
+ "(short sentences, active voice, no jargon, no filler). " \
76
+ "Input: a file path, or Markdown text. Returns one line per finding, or 'clean' when " \
77
+ "nothing is flagged. Fix the findings, then call again until clean.",
78
+ "inputSchema" => {
79
+ "type" => "object",
80
+ "properties" => {
81
+ "path" => {"type" => "string", "description" => "File to lint"},
82
+ "text" => {"type" => "string", "description" => "Markdown text to lint instead of a file"}
83
+ }
84
+ }
85
+ }
86
+ end
87
+
88
+ def tools_call(id, params, linter)
89
+ tool = params["name"]
90
+ return error(id, -32602, "Unknown tool: #{tool}") unless tool == "lint"
91
+ arguments = params["arguments"]
92
+ arguments = {} unless arguments.is_a?(Hash)
93
+ path = arguments["path"]
94
+ text = arguments["text"]
95
+ return ok(id, tool_error("give the path or the text, not both.")) if path && text
96
+ unless text
97
+ return ok(id, tool_error("the path or text argument is required.")) if path.nil? || path.to_s.empty?
98
+ return ok(id, tool_error("#{path} is a directory. Give one file.")) if File.directory?(path)
99
+ return ok(id, tool_error("file not found: #{path}")) unless File.exist?(path)
100
+ end
101
+ findings =
102
+ begin
103
+ text ? linter.lint_text(text) : linter.lint_file(path)
104
+ rescue SimpleEnglish::Config::ConfigError => e
105
+ return ok(id, tool_error("invalid .simple-english.yml: #{e.message}"))
106
+ end
107
+ if findings.nil?
108
+ # A tool error, not a protocol error: clients show the text to the
109
+ # model, and this message is the one that tells it how to recover.
110
+ ok(id, tool_error("the se daemon did not answer. Run `se serve --detached` and retry."))
111
+ else
112
+ ok(id, {"content" => [{"type" => "text", "text" => render(text ? "-" : path, findings)}], "isError" => false})
113
+ end
114
+ end
115
+
116
+ def render(path, findings)
117
+ return "clean" if findings.empty?
118
+ findings.map { |finding| finding.to_line(path) }.join("\n")
119
+ end
120
+
121
+ def tool_error(text)
122
+ {"content" => [{"type" => "text", "text" => text}], "isError" => true}
123
+ end
124
+
125
+ def ok(id, result)
126
+ {"jsonrpc" => "2.0", "id" => id, "result" => result}
127
+ end
128
+
129
+ def error(id, code, message)
130
+ {"jsonrpc" => "2.0", "id" => id, "error" => {"code" => code, "message" => message}}
131
+ end
132
+ end
133
+ end