simple_english 0.5.0 → 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.
@@ -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?
@@ -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
@@ -15,7 +15,7 @@ module SimpleEnglish
15
15
 
16
16
  module_function
17
17
 
18
- def run(io: $stdin, out: $stdout, linter: method(:default_linter))
18
+ def run(io: $stdin, out: $stdout, linter: Linter)
19
19
  # A piped stdout buffers until 4 KB or EOF. An MCP client keeps
20
20
  # stdin open while it waits for the answer, so an unflushed
21
21
  # response never arrives and the client drops the server.
@@ -26,6 +26,7 @@ module SimpleEnglish
26
26
  begin
27
27
  request = JSON.parse(line)
28
28
  rescue JSON::ParserError
29
+ out.puts JSON.generate(error(nil, -32700, "Parse error"))
29
30
  next
30
31
  end
31
32
  next unless request.is_a?(Hash)
@@ -41,15 +42,6 @@ module SimpleEnglish
41
42
  end
42
43
  end
43
44
 
44
- # The CLI's lint pipeline as a callable: .simple-english.yml decides
45
- # which paths and rules count, then the daemon lints the file.
46
- def default_linter(path)
47
- config = SimpleEnglish::Config.load
48
- return [] if SimpleEnglish::Config.ignore?(config, path)
49
- findings = SimpleEnglish.lint_file(path)
50
- findings.nil? ? nil : SimpleEnglish::Config.filter(config, path, findings)
51
- end
52
-
53
45
  def respond(request, linter:)
54
46
  return nil unless request.key?("id")
55
47
  id = request.fetch("id")
@@ -59,6 +51,7 @@ module SimpleEnglish
59
51
  params = {} unless params.is_a?(Hash)
60
52
  case method
61
53
  when "initialize" then ok(id, initialize_result(params["protocolVersion"]))
54
+ when "ping" then ok(id, {})
62
55
  when "tools/list" then ok(id, {"tools" => [lint_tool]})
63
56
  when "tools/call" then tools_call(id, params, linter)
64
57
  else error(id, -32601, "Method not found: #{method}")
@@ -80,12 +73,14 @@ module SimpleEnglish
80
73
  "description" =>
81
74
  "Lint Markdown prose and code comments against plain-English rules " \
82
75
  "(short sentences, active voice, no jargon, no filler). " \
83
- "Input: a file path. Returns one line per finding, or 'clean' when " \
76
+ "Input: a file path, or Markdown text. Returns one line per finding, or 'clean' when " \
84
77
  "nothing is flagged. Fix the findings, then call again until clean.",
85
78
  "inputSchema" => {
86
79
  "type" => "object",
87
- "properties" => {"path" => {"type" => "string", "description" => "File to lint"}},
88
- "required" => ["path"]
80
+ "properties" => {
81
+ "path" => {"type" => "string", "description" => "File to lint"},
82
+ "text" => {"type" => "string", "description" => "Markdown text to lint instead of a file"}
83
+ }
89
84
  }
90
85
  }
91
86
  end
@@ -96,12 +91,16 @@ module SimpleEnglish
96
91
  arguments = params["arguments"]
97
92
  arguments = {} unless arguments.is_a?(Hash)
98
93
  path = arguments["path"]
99
- return ok(id, tool_error("the path argument is required.")) if path.nil? || path.to_s.empty?
100
- return ok(id, tool_error("#{path} is a directory. Give one file.")) if File.directory?(path)
101
- return ok(id, tool_error("file not found: #{path}")) unless File.exist?(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
102
101
  findings =
103
102
  begin
104
- linter.call(path)
103
+ text ? linter.lint_text(text) : linter.lint_file(path)
105
104
  rescue SimpleEnglish::Config::ConfigError => e
106
105
  return ok(id, tool_error("invalid .simple-english.yml: #{e.message}"))
107
106
  end
@@ -110,7 +109,7 @@ module SimpleEnglish
110
109
  # model, and this message is the one that tells it how to recover.
111
110
  ok(id, tool_error("the se daemon did not answer. Run `se serve --detached` and retry."))
112
111
  else
113
- ok(id, {"content" => [{"type" => "text", "text" => render(path, findings)}], "isError" => false})
112
+ ok(id, {"content" => [{"type" => "text", "text" => render(text ? "-" : path, findings)}], "isError" => false})
114
113
  end
115
114
  end
116
115
 
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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.
4
+ # given on the command line), disabled-rules (rule IDs dropped
5
+ # from every file), and rule-ignores (a rule dropped only on the
6
+ # listed path globs, fired everywhere else). Loaded from the CWD.
6
7
 
7
8
  module SimpleEnglish
8
9
  module Config
9
10
  ConfigError = Class.new(StandardError)
10
- DEFAULT = {ignore: [], disabled_rules: []}.freeze
11
+ DEFAULT = {ignore: [], disabled_rules: [], rule_ignores: {}}.freeze
11
12
 
12
13
  module_function
13
14
 
@@ -17,18 +18,33 @@ module SimpleEnglish
17
18
  require "yaml"
18
19
  data = YAML.safe_load_file(file) || {}
19
20
  {ignore: Array(data["ignore"]),
20
- disabled_rules: Array(data["disabled-rules"])}
21
+ disabled_rules: Array(data["disabled-rules"]),
22
+ rule_ignores: load_rule_ignores(data["rule-ignores"])}
21
23
  rescue Psych::SyntaxError => e
22
24
  raise ConfigError, ".simple-english.yml: #{e.message}"
23
25
  end
24
26
 
27
+ # Loose like the other keys: a rule ID with no entry means the
28
+ # rule fires everywhere, so a wrong ID surfaces its findings
29
+ # instead of hiding them.
30
+ def load_rule_ignores(data)
31
+ return {} unless data.is_a?(Hash)
32
+ data.to_h { |rule, globs| [rule, Array(globs)] }
33
+ end
34
+
25
35
  # Hooks pass absolute paths while ignore globs are written
26
36
  # relative to the repo root (the CWD). Match both forms.
27
37
  # realpath because CWD may be a symlink-resolved path while the
28
38
  # caller's string still carries the symlink (TMPDIR on macOS).
29
39
  def ignore?(config, path)
40
+ path_matches?(config[:ignore], path)
41
+ end
42
+
43
+ def path_matches?(globs, path)
30
44
  candidates = [path, relative_candidate(path)].compact
31
- candidates.any? { |candidate| config[:ignore].any? { |pattern| matches?(pattern, candidate) } }
45
+ globs.any? do |pattern|
46
+ candidates.any? { |candidate| matches?(pattern, candidate) }
47
+ end
32
48
  end
33
49
 
34
50
  def relative_candidate(path)
@@ -54,10 +70,16 @@ module SimpleEnglish
54
70
  end.join("/") + "\\z")
55
71
  end
56
72
 
57
- def filter(config, path, findings)
58
- return [] if ignore?(config, path)
73
+ # rule-ignores needs the finding's file, which findings do not
74
+ # carry, so the caller passes path in. A nil path (stdin, MCP
75
+ # text) has no path to match, so every rule fires.
76
+ def filter(config, findings, path: nil)
59
77
  disabled = config[:disabled_rules]
60
- findings.reject { |finding| disabled.include?(finding.rule) }
78
+ rule_ignores = config[:rule_ignores] || {}
79
+ findings.reject do |finding|
80
+ disabled.include?(finding.rule) ||
81
+ (path && path_matches?(rule_ignores[finding.rule] || [], path))
82
+ end
61
83
  end
62
84
  end
63
85
  end