simple_english 0.6.0-x86_64-linux

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 (40) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +212 -0
  4. data/THIRD_PARTY_NOTICES.md +72 -0
  5. data/bin/se +6 -0
  6. data/docs/RULES.md +496 -0
  7. data/lib/simple_english/cli.rb +183 -0
  8. data/lib/simple_english/client/daemon.rb +233 -0
  9. data/lib/simple_english/client/language_tool.rb +95 -0
  10. data/lib/simple_english/daemon/engine.rb +40 -0
  11. data/lib/simple_english/daemon/http.rb +82 -0
  12. data/lib/simple_english/daemon/server.rb +285 -0
  13. data/lib/simple_english/lint/annotated_text.rb +117 -0
  14. data/lib/simple_english/lint/counts.rb +40 -0
  15. data/lib/simple_english/lint/extractor.rb +60 -0
  16. data/lib/simple_english/lint/finding.rb +24 -0
  17. data/lib/simple_english/lint/lint_plan.rb +22 -0
  18. data/lib/simple_english/lint/markdown.rb +121 -0
  19. data/lib/simple_english/lint/paragraph.rb +10 -0
  20. data/lib/simple_english/lint/plain_text.rb +30 -0
  21. data/lib/simple_english/lint/result.rb +18 -0
  22. data/lib/simple_english/lint/segment.rb +11 -0
  23. data/lib/simple_english/lint/span.rb +8 -0
  24. data/lib/simple_english/lint/suppressions.rb +37 -0
  25. data/lib/simple_english/model_context_protocol.rb +134 -0
  26. data/lib/simple_english/setup/config.rb +63 -0
  27. data/lib/simple_english/setup/fingerprint.rb +38 -0
  28. data/lib/simple_english/setup/install.rb +40 -0
  29. data/lib/simple_english/setup/languagetool.rb +23 -0
  30. data/lib/simple_english/version.rb +5 -0
  31. data/lib/simple_english.rb +60 -0
  32. data/libexec/simple_english/languagetool-server +4 -0
  33. data/libexec/simple_english/languagetool-server.sbom.json +9044 -0
  34. data/licenses/LGPL-2.1.txt +501 -0
  35. data/licenses/OpenJDK-LICENSE.txt +347 -0
  36. data/licenses/languagetool-resource-en/12dicts-readme.html +738 -0
  37. data/licenses/languagetool-resource-en/agid-readme.txt +340 -0
  38. data/licenses/languagetool-resource-en/pos-readme.txt +115 -0
  39. data/rules/simple-english.xml +621 -0
  40. metadata +185 -0
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleEnglish
4
+ # The plain-text payload for /v2/check, duck-compatible with
5
+ # AnnotatedText::Result: #lt_params and #locate.
6
+ PlainText = Struct.new(:text) do
7
+ def lt_params
8
+ {"text" => text}
9
+ end
10
+
11
+ # LanguageTool reports offsets in Java UTF-16 code units.
12
+ # Return a 1-based line and UTF-16 column for a 0-based offset.
13
+ def locate(utf16_offset)
14
+ line = 1
15
+ column = 1
16
+ units = 0
17
+ text.each_char do |char|
18
+ return [line, column] if units >= utf16_offset
19
+ units += (char.ord > 0xFFFF) ? 2 : 1
20
+ if char == "\n"
21
+ line += 1
22
+ column = 1
23
+ else
24
+ column += (char.ord > 0xFFFF) ? 2 : 1
25
+ end
26
+ end
27
+ [line, column]
28
+ end
29
+ end
30
+ end
@@ -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
+ # 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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleEnglish
4
+ module Extractor
5
+ # One comment: its text and byte range in the source file.
6
+ Span = Struct.new(:text, :start_byte, :end_byte)
7
+ end
8
+ 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
@@ -0,0 +1,134 @@
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: method(:default_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
+ next
30
+ end
31
+ next unless request.is_a?(Hash)
32
+ response =
33
+ begin
34
+ respond(request, linter: linter)
35
+ rescue => e
36
+ # A broken tool call (unreadable file, daemon hiccup) must not
37
+ # kill the loop: answer it, then serve the next line.
38
+ request.key?("id") ? error(request["id"], -32603, "lint failed: #{e.class}: #{e.message}") : nil
39
+ end
40
+ out.puts JSON.generate(response) if response
41
+ end
42
+ end
43
+
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
+ def respond(request, linter:)
54
+ return nil unless request.key?("id")
55
+ id = request.fetch("id")
56
+ method = request["method"]
57
+ return error(id, -32600, "Invalid Request") unless method.is_a?(String)
58
+ params = request["params"]
59
+ params = {} unless params.is_a?(Hash)
60
+ case method
61
+ when "initialize" then ok(id, initialize_result(params["protocolVersion"]))
62
+ when "tools/list" then ok(id, {"tools" => [lint_tool]})
63
+ when "tools/call" then tools_call(id, params, linter)
64
+ else error(id, -32601, "Method not found: #{method}")
65
+ end
66
+ end
67
+
68
+ def initialize_result(requested_version)
69
+ version = SUPPORTED_VERSIONS.include?(requested_version) ? requested_version : LATEST_VERSION
70
+ {
71
+ "protocolVersion" => version,
72
+ "capabilities" => {"tools" => {}},
73
+ "serverInfo" => {"name" => SERVER_NAME, "version" => SimpleEnglish::VERSION}
74
+ }
75
+ end
76
+
77
+ def lint_tool
78
+ {
79
+ "name" => "lint",
80
+ "description" =>
81
+ "Lint Markdown prose and code comments against plain-English rules " \
82
+ "(short sentences, active voice, no jargon, no filler). " \
83
+ "Input: a file path. Returns one line per finding, or 'clean' when " \
84
+ "nothing is flagged. Fix the findings, then call again until clean.",
85
+ "inputSchema" => {
86
+ "type" => "object",
87
+ "properties" => {"path" => {"type" => "string", "description" => "File to lint"}},
88
+ "required" => ["path"]
89
+ }
90
+ }
91
+ end
92
+
93
+ def tools_call(id, params, linter)
94
+ tool = params["name"]
95
+ return error(id, -32602, "Unknown tool: #{tool}") unless tool == "lint"
96
+ arguments = params["arguments"]
97
+ arguments = {} unless arguments.is_a?(Hash)
98
+ 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)
102
+ findings =
103
+ begin
104
+ linter.call(path)
105
+ rescue SimpleEnglish::Config::ConfigError => e
106
+ return ok(id, tool_error("invalid .simple-english.yml: #{e.message}"))
107
+ end
108
+ if findings.nil?
109
+ # A tool error, not a protocol error: clients show the text to the
110
+ # model, and this message is the one that tells it how to recover.
111
+ ok(id, tool_error("the se daemon did not answer. Run `se serve --detached` and retry."))
112
+ else
113
+ ok(id, {"content" => [{"type" => "text", "text" => render(path, findings)}], "isError" => false})
114
+ end
115
+ end
116
+
117
+ def render(path, findings)
118
+ return "clean" if findings.empty?
119
+ findings.map { |finding| finding.to_line(path) }.join("\n")
120
+ end
121
+
122
+ def tool_error(text)
123
+ {"content" => [{"type" => "text", "text" => text}], "isError" => true}
124
+ end
125
+
126
+ def ok(id, result)
127
+ {"jsonrpc" => "2.0", "id" => id, "result" => result}
128
+ end
129
+
130
+ def error(id, code, message)
131
+ {"jsonrpc" => "2.0", "id" => id, "error" => {"code" => code, "message" => message}}
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
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.
6
+
7
+ module SimpleEnglish
8
+ module Config
9
+ ConfigError = Class.new(StandardError)
10
+ DEFAULT = {ignore: [], disabled_rules: []}.freeze
11
+
12
+ module_function
13
+
14
+ def load(dir = Dir.pwd)
15
+ file = File.join(dir, ".simple-english.yml")
16
+ return DEFAULT unless File.exist?(file)
17
+ require "yaml"
18
+ data = YAML.safe_load_file(file) || {}
19
+ {ignore: Array(data["ignore"]),
20
+ disabled_rules: Array(data["disabled-rules"])}
21
+ rescue Psych::SyntaxError => e
22
+ raise ConfigError, ".simple-english.yml: #{e.message}"
23
+ end
24
+
25
+ # Hooks pass absolute paths while ignore globs are written
26
+ # relative to the repo root (the CWD). Match both forms.
27
+ # realpath because CWD may be a symlink-resolved path while the
28
+ # caller's string still carries the symlink (TMPDIR on macOS).
29
+ def ignore?(config, path)
30
+ candidates = [path, relative_candidate(path)].compact
31
+ candidates.any? { |candidate| config[:ignore].any? { |pattern| matches?(pattern, candidate) } }
32
+ end
33
+
34
+ def relative_candidate(path)
35
+ pwd = Dir.pwd + "/"
36
+ resolved = File.exist?(path) ? File.realpath(path) : File.expand_path(path)
37
+ resolved.start_with?(pwd) ? resolved.delete_prefix(pwd) : nil
38
+ end
39
+
40
+ # File.fnmatch has no globstar: `**` never crosses directories.
41
+ # Translate instead: `**` as a whole segment is any depth, `*` and
42
+ # `?` stay within one segment.
43
+ def matches?(pattern, path)
44
+ glob_to_regex(pattern).match?(path)
45
+ end
46
+
47
+ def glob_to_regex(pattern)
48
+ Regexp.new("\\A" + pattern.split("/").map do |segment|
49
+ if segment == "**"
50
+ "(?:[^/]+/)*[^/]*"
51
+ else
52
+ Regexp.escape(segment).gsub("\\*", "[^/]*").gsub("\\?", "[^/]")
53
+ end
54
+ end.join("/") + "\\z")
55
+ end
56
+
57
+ def filter(config, path, findings)
58
+ return [] if ignore?(config, path)
59
+ disabled = config[:disabled_rules]
60
+ findings.reject { |finding| disabled.include?(finding.rule) }
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Fingerprints for the daemon handshake. `gem` identifies the code and
4
+ # built-in rules a daemon runs: any release that changes lint behavior
5
+ # changes it, and nothing else does. `sha` covers the staged rule
6
+ # content wherever the caller resolved it.
7
+
8
+ require_relative "languagetool"
9
+
10
+ module SimpleEnglish
11
+ module Fingerprint
12
+ module_function
13
+
14
+ # Hash everything the daemon can execute, not a curated list: a
15
+ # curated list goes stale the first time a new file is forgotten,
16
+ # and a missed file means silent staleness, the exact bug the
17
+ # handshake exists to catch. The accepted cost: a release that
18
+ # only changes caller-side code triggers one needless restart.
19
+ # ponytail: whole-lib glob. If restarts ever hurt, curate per-file.
20
+ def gem
21
+ files = Dir.glob(File.expand_path("../../**/*.rb", __dir__)).sort
22
+ files << LanguageTool::RULES_FILE
23
+ # Each file is length-prefixed: bare concatenation cannot tell
24
+ # ["ab", "c"] from ["a", "bc"], so bytes moved across a file
25
+ # boundary leave the digest unchanged.
26
+ framed = files.map do |path|
27
+ content = File.read(path)
28
+ "#{content.bytesize}\n#{content}"
29
+ end
30
+ sha(framed.join)
31
+ end
32
+
33
+ def sha(content)
34
+ require "digest"
35
+ ::Digest::SHA256.hexdigest(content)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Resolves the bundled native LanguageTool server once, at the process edge.
4
+ # Tests and source builds can select an explicit executable through the
5
+ # environment. Released platform gems provide the default under libexec.
6
+
7
+ require_relative "languagetool"
8
+
9
+ module SimpleEnglish
10
+ class Install
11
+ class SetupError < StandardError
12
+ end
13
+
14
+ attr_reader :executable
15
+
16
+ def initialize(executable:)
17
+ @executable = File.expand_path(executable)
18
+ end
19
+
20
+ def self.from_env(env = ENV)
21
+ executable = env.fetch(LanguageTool::EXECUTABLE_ENV,
22
+ LanguageTool::BUNDLED_EXECUTABLE)
23
+ new(executable: executable)
24
+ end
25
+
26
+ def executable?
27
+ File.file?(executable) && File.executable?(executable)
28
+ end
29
+
30
+ def executable!
31
+ return executable if executable?
32
+ raise SetupError, setup_error
33
+ end
34
+
35
+ def setup_error
36
+ "lint engine not found or not executable at #{executable}. " \
37
+ "Install a supported platform build of this gem."
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Pinned LanguageTool facts shared by the native build and Ruby runtime.
4
+
5
+ module SimpleEnglish
6
+ module LanguageTool
7
+ RULES_FILE = File.expand_path("../../../rules/simple-english.xml", __dir__)
8
+ LT_VERSION = "6.6"
9
+ EXECUTABLE_ENV = "SE_LANGUAGETOOL_EXECUTABLE"
10
+ BUNDLED_EXECUTABLE = File.expand_path(
11
+ "../../../libexec/simple_english/languagetool-server", __dir__
12
+ )
13
+ TIMEOUT_SECONDS = 30
14
+
15
+ module_function
16
+
17
+ def rule_ids(path = RULES_FILE)
18
+ File.read(path, encoding: "UTF-8").scan(/<(?:rule|rulegroup)\b[^>]*>/).filter_map do |tag|
19
+ tag[/\bid="([^"]+)"/, 1]
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleEnglish
4
+ VERSION = "0.6.0"
5
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Lint Markdown prose with the SimpleEnglish Plain-mode rules.
4
+ # Pattern rules run on LanguageTool. Counting rules run here.
5
+ # This file is the composition root. The pieces live in lib/simple_english/.
6
+
7
+ require_relative "simple_english/version"
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/model_context_protocol"
25
+ require_relative "simple_english/daemon/server"
26
+
27
+ module SimpleEnglish
28
+ module_function
29
+
30
+ # Returns findings, or nil when the daemon is unreachable. The
31
+ # caller (bin/se) owns the exit status. Diagnostics go to
32
+ # stderr here.
33
+ def lint_text(text)
34
+ return nil unless Client.ensure_up(install: Install.from_env)
35
+ Client.lint(text)
36
+ end
37
+
38
+ # One entry point for files. LintPlan decides what kind of lint
39
+ # this is. Both tiers share it, so they cannot drift. Code files
40
+ # lint comments through the daemon. nil means unreachable:
41
+ # the caller decides how fatal that is.
42
+ def lint_file(path, text = File.read(path))
43
+ language = Extractor.language_for(path)
44
+ plan = LintPlan.call(text, language)
45
+ return [] if plan.nil?
46
+ findings =
47
+ if plan == :prose
48
+ lint_text(text)
49
+ else
50
+ return nil unless Client.ensure_up(install: Install.from_env)
51
+ result = Client.lint(text, language: language)
52
+ if result.nil?
53
+ warn "error: se daemon did not answer. Run `#{Client.runner} serve` and read its output."
54
+ end
55
+ result
56
+ end
57
+ return nil unless findings
58
+ Suppressions.filter(text, findings)
59
+ end
60
+ end
@@ -0,0 +1,4 @@
1
+ [diffend] Oversized file quarantined before diffing.
2
+ name: data/libexec/simple_english/languagetool-server
3
+ size: 131074312 bytes
4
+ sha256: 34fa51475b10065dfeb151d04c9c2e7bda4a011c8b03b0088110b7285c9ee184