textus 0.2.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.
@@ -0,0 +1,41 @@
1
+ require "json"
2
+
3
+ module Textus
4
+ module Entry
5
+ # JSON entry storage. Top-level must be an object so we can carry _meta.
6
+ module Json
7
+ def self.parse(raw, path: nil)
8
+ raw = raw.dup.force_encoding(Encoding::UTF_8)
9
+ raise BadFrontmatter.new(path, "entry is not valid UTF-8") unless raw.valid_encoding?
10
+
11
+ begin
12
+ parsed = ::JSON.parse(raw)
13
+ rescue ::JSON::ParserError => e
14
+ raise BadFrontmatter.new(path, "JSON parse failed: #{e.message}")
15
+ end
16
+ raise BadFrontmatter.new(path, "JSON top-level must be an object") unless parsed.is_a?(Hash)
17
+
18
+ meta = parsed["_meta"]
19
+ fm = meta.is_a?(Hash) ? meta : {}
20
+ { "frontmatter" => fm, "body" => raw, "content" => parsed }
21
+ end
22
+
23
+ def self.serialize(frontmatter:, body:, content: nil)
24
+ _ = frontmatter
25
+ if content.is_a?(Hash)
26
+ out = ::JSON.pretty_generate(content)
27
+ out += "\n" unless out.end_with?("\n")
28
+ out
29
+ elsif body && !body.to_s.empty?
30
+ b = body.to_s
31
+ b += "\n" unless b.end_with?("\n")
32
+ b
33
+ else
34
+ raise UsageError.new("json serialize requires :content or :body")
35
+ end
36
+ end
37
+
38
+ def self.extensions = [".json"]
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,39 @@
1
+ require "yaml"
2
+
3
+ module Textus
4
+ module Entry
5
+ # Markdown with YAML frontmatter. Original Entry implementation.
6
+ module Markdown
7
+ def self.parse(raw, path: nil)
8
+ raw = raw.dup.force_encoding(Encoding::UTF_8)
9
+ raise BadFrontmatter.new(path, "entry is not valid UTF-8") unless raw.valid_encoding?
10
+ return { "frontmatter" => {}, "body" => raw, "content" => nil } unless raw.start_with?("---\n") || raw.start_with?("---\r\n")
11
+
12
+ lines = raw.split(/\r?\n/, -1)
13
+ close_idx = lines[1..].index("---")
14
+ raise BadFrontmatter.new(path, "frontmatter not terminated") unless close_idx
15
+
16
+ close_idx += 1
17
+ fm_yaml = lines[1...close_idx].join("\n")
18
+ body = lines[(close_idx + 1)..].join("\n")
19
+ begin
20
+ fm = fm_yaml.strip.empty? ? {} : ::YAML.safe_load(fm_yaml, permitted_classes: [Date, Time], aliases: false)
21
+ rescue Psych::SyntaxError => e
22
+ raise BadFrontmatter.new(path, "YAML parse failed: #{e.message}")
23
+ end
24
+ fm = {} unless fm.is_a?(Hash)
25
+ { "frontmatter" => fm, "body" => body, "content" => nil }
26
+ end
27
+
28
+ def self.serialize(frontmatter:, body:, content: nil)
29
+ _ = content # markdown ignores content
30
+ fm_yaml = frontmatter.empty? ? "" : ::YAML.dump(frontmatter).sub(/\A---\n/, "")
31
+ body = body.to_s
32
+ body += "\n" unless body.empty? || body.end_with?("\n")
33
+ "---\n#{fm_yaml}---\n#{body}"
34
+ end
35
+
36
+ def self.extensions = [".md"]
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,23 @@
1
+ module Textus
2
+ module Entry
3
+ # Plain-text entry storage. No frontmatter or structured content.
4
+ module Text
5
+ def self.parse(raw, path: nil)
6
+ raw = raw.dup.force_encoding(Encoding::UTF_8)
7
+ raise BadFrontmatter.new(path, "entry is not valid UTF-8") unless raw.valid_encoding?
8
+
9
+ { "frontmatter" => {}, "body" => raw, "content" => nil }
10
+ end
11
+
12
+ def self.serialize(frontmatter:, body:, content: nil)
13
+ _ = frontmatter
14
+ _ = content
15
+ b = body.to_s
16
+ b += "\n" unless b.empty? || b.end_with?("\n")
17
+ b
18
+ end
19
+
20
+ def self.extensions = [".txt"]
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,39 @@
1
+ require "yaml"
2
+
3
+ module Textus
4
+ module Entry
5
+ # YAML entry storage. Top-level must be a mapping so we can carry _meta.
6
+ module Yaml
7
+ def self.parse(raw, path: nil)
8
+ raw = raw.dup.force_encoding(Encoding::UTF_8)
9
+ raise BadFrontmatter.new(path, "entry is not valid UTF-8") unless raw.valid_encoding?
10
+
11
+ begin
12
+ parsed = ::YAML.safe_load(raw, permitted_classes: [Date, Time], aliases: false)
13
+ rescue Psych::SyntaxError, Psych::AliasesNotEnabled, Psych::DisallowedClass => e
14
+ raise BadFrontmatter.new(path, "YAML parse failed: #{e.message}")
15
+ end
16
+ raise BadFrontmatter.new(path, "YAML top-level must be a mapping") unless parsed.is_a?(Hash)
17
+
18
+ meta = parsed["_meta"]
19
+ fm = meta.is_a?(Hash) ? meta : {}
20
+ { "frontmatter" => fm, "body" => raw, "content" => parsed }
21
+ end
22
+
23
+ def self.serialize(frontmatter:, body:, content: nil)
24
+ _ = frontmatter
25
+ if content.is_a?(Hash)
26
+ ::YAML.dump(content).sub(/\A---\n/, "")
27
+ elsif body && !body.to_s.empty?
28
+ b = body.to_s
29
+ b += "\n" unless b.end_with?("\n")
30
+ b
31
+ else
32
+ raise UsageError.new("yaml serialize requires :content or :body")
33
+ end
34
+ end
35
+
36
+ def self.extensions = [".yaml", ".yml"]
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,30 @@
1
+ require_relative "entry/markdown"
2
+ require_relative "entry/json"
3
+ require_relative "entry/yaml"
4
+ require_relative "entry/text"
5
+
6
+ module Textus
7
+ # Public entry-format dispatcher.
8
+ module Entry
9
+ SEP = "---".freeze
10
+
11
+ STRATEGIES = {
12
+ "markdown" => Markdown,
13
+ "json" => Json,
14
+ "yaml" => Yaml,
15
+ "text" => Text,
16
+ }.freeze
17
+
18
+ def self.for_format(format)
19
+ STRATEGIES.fetch(format.to_s) { raise UsageError.new("unknown entry format: #{format.inspect}") }
20
+ end
21
+
22
+ def self.parse(raw, path: nil, format: "markdown")
23
+ for_format(format).parse(raw, path: path)
24
+ end
25
+
26
+ def self.serialize(frontmatter: {}, body: "", content: nil, format: "markdown")
27
+ for_format(format).serialize(frontmatter: frontmatter, body: body, content: content)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,168 @@
1
+ module Textus
2
+ class Error < StandardError
3
+ attr_reader :code, :details, :exit_code, :hint
4
+
5
+ def initialize(code, message, details: {}, exit_code: 1, hint: nil)
6
+ super(message)
7
+ @code = code
8
+ @details = details
9
+ @exit_code = exit_code
10
+ @hint = hint
11
+ end
12
+
13
+ def to_envelope
14
+ env = {
15
+ "protocol" => Textus::PROTOCOL,
16
+ "ok" => false,
17
+ "code" => @code,
18
+ "message" => message,
19
+ "details" => @details,
20
+ }
21
+ env["hint"] = @hint if @hint
22
+ env
23
+ end
24
+ end
25
+
26
+ class UnknownKey < Error
27
+ attr_reader :suggestions
28
+
29
+ def initialize(key, suggestions: [])
30
+ @suggestions = Array(suggestions)
31
+ details = { "key" => key }
32
+ details["suggestions"] = @suggestions unless @suggestions.empty?
33
+ msg = "key '#{key}' does not resolve"
34
+ msg += "; did you mean: #{@suggestions.join(", ")}" unless @suggestions.empty?
35
+ hint =
36
+ if @suggestions.empty?
37
+ "run 'textus list --format=json' to see all keys"
38
+ else
39
+ "did you mean: #{@suggestions.join(", ")}"
40
+ end
41
+ super("unknown_key", msg, details: details, hint: hint)
42
+ end
43
+ end
44
+
45
+ class BadFrontmatter < Error
46
+ def initialize(path, m, hint: nil)
47
+ hint ||= default_hint_for(path, m)
48
+ super("bad_frontmatter", m, details: { "path" => path }, hint: hint)
49
+ end
50
+
51
+ private
52
+
53
+ def default_hint_for(path, m)
54
+ if m.is_a?(String) && (match = m.match(/frontmatter name '([^']+)' does not match basename '([^']+)'/))
55
+ name, basename = match.captures
56
+ ext = File.extname(path)
57
+ "rename the file to '#{name}#{ext}' or change frontmatter name: to '#{basename}'"
58
+ else
59
+ "open #{path} and check the YAML frontmatter for syntax errors"
60
+ end
61
+ end
62
+ end
63
+
64
+ class BadContent < Error
65
+ def initialize(path, m)
66
+ super(
67
+ "bad_content", m,
68
+ details: { "path" => path },
69
+ hint: "JSON/YAML parse failed; run the file through 'jq .' or 'yq .' to find the syntax error",
70
+ )
71
+ end
72
+ end
73
+
74
+ class SchemaViolation < Error
75
+ def initialize(d)
76
+ hint =
77
+ if d.is_a?(Hash) && d["missing"]
78
+ "add the missing field(s) to the entry's frontmatter: #{Array(d["missing"]).join(", ")}"
79
+ elsif d.is_a?(Hash) && d["field"]
80
+ "fix the field '#{d["field"]}' in the entry's frontmatter (#{d["reason"]})"
81
+ end
82
+ super("schema_violation", "schema violation", details: d, hint: hint)
83
+ end
84
+ end
85
+
86
+ class WriteForbidden < Error
87
+ def initialize(k, z, writers: nil)
88
+ writers_str =
89
+ if writers && !writers.empty?
90
+ writers.join(", ")
91
+ else
92
+ "the role(s) listed in the manifest 'writable_by:'"
93
+ end
94
+ details = { "key" => k, "zone" => z }
95
+ details["writers"] = writers if writers
96
+ super(
97
+ "write_forbidden",
98
+ "zone '#{z}' is not agent-writable for key '#{k}'",
99
+ details: details,
100
+ hint: "this zone is writable by #{writers_str}; pass --as=<role>",
101
+ )
102
+ end
103
+ end
104
+
105
+ class EtagMismatch < Error
106
+ def initialize(k, w, g)
107
+ super(
108
+ "etag_mismatch", "etag mismatch on '#{k}'",
109
+ details: { "key" => k, "wanted" => w, "got" => g },
110
+ hint: "another writer changed this key; run 'textus get #{k}' to fetch the latest etag",
111
+ )
112
+ end
113
+ end
114
+
115
+ class IoError < Error
116
+ def initialize(m) = super("io_error", m, exit_code: 64)
117
+ end
118
+
119
+ class UsageError < Error
120
+ def initialize(m, hint: nil) = super("usage", m, exit_code: 2, hint: hint)
121
+ end
122
+
123
+ class InvalidRole < Error
124
+ def initialize(r)
125
+ super(
126
+ "invalid_role", "role '#{r}' is not declared in any zone",
127
+ details: { "role" => r },
128
+ hint: "valid roles are declared in .textus/manifest.yaml under zones[].writable_by",
129
+ )
130
+ end
131
+ end
132
+
133
+ class InvalidProjection < Error
134
+ def initialize(m) = super("invalid_projection", m)
135
+ end
136
+
137
+ class TemplateError < Error
138
+ def initialize(m, template_name: nil)
139
+ hint =
140
+ ("expected at .textus/templates/#{template_name}; add the file or update the entry's template: field" if template_name)
141
+ super("template_error", m, hint: hint)
142
+ end
143
+ end
144
+
145
+ class BadRender < Error
146
+ def initialize(m, format: nil)
147
+ hint =
148
+ if format
149
+ "the template rendered invalid #{format}; try rendering with mock data and parsing the output before re-running build"
150
+ else
151
+ "the template rendered invalid content; try rendering with mock data and parsing the output before re-running build"
152
+ end
153
+ super("bad_render", m, hint: hint)
154
+ end
155
+ end
156
+
157
+ class PublishError < Error
158
+ def initialize(m, target: nil)
159
+ hint =
160
+ ("file at #{target} wasn't published by textus; back it up and delete it, or move it under .textus/zones/" if target)
161
+ super("publish_error", m, details: target ? { "target" => target } : {}, hint: hint)
162
+ end
163
+ end
164
+
165
+ class ProposalError < Error
166
+ def initialize(m) = super("proposal_error", m)
167
+ end
168
+ end
@@ -0,0 +1,13 @@
1
+ require "digest"
2
+
3
+ module Textus
4
+ module Etag
5
+ def self.for_bytes(bytes)
6
+ "sha256:#{Digest::SHA256.hexdigest(bytes)}"
7
+ end
8
+
9
+ def self.for_file(path)
10
+ for_bytes(File.binread(path))
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,48 @@
1
+ module Textus
2
+ class ExtensionRegistry
3
+ EVENTS = %i[put delete refresh build accept].freeze
4
+
5
+ def initialize
6
+ @fetchers = {}
7
+ @reducers = {}
8
+ @hooks = {}
9
+ end
10
+
11
+ def register_fetcher(name, &blk)
12
+ name = name.to_sym
13
+ raise UsageError.new("fetcher '#{name}' already registered") if @fetchers.key?(name)
14
+
15
+ @fetchers[name] = blk
16
+ end
17
+
18
+ def register_reducer(name, &blk)
19
+ name = name.to_sym
20
+ raise UsageError.new("reducer '#{name}' already registered") if @reducers.key?(name)
21
+
22
+ @reducers[name] = blk
23
+ end
24
+
25
+ def register_hook(event, name, &blk)
26
+ event = event.to_sym
27
+ raise UsageError.new("unknown event: #{event}") unless EVENTS.include?(event)
28
+
29
+ (@hooks[event] ||= []) << { name: name.to_sym, callable: blk }
30
+ end
31
+
32
+ def fetcher(name)
33
+ @fetchers[name.to_sym] or raise UsageError.new("unknown fetcher: #{name}")
34
+ end
35
+
36
+ def reducer(name)
37
+ @reducers[name.to_sym] or raise UsageError.new("unknown reducer: #{name}")
38
+ end
39
+
40
+ def hooks(event)
41
+ @hooks[event.to_sym] || []
42
+ end
43
+
44
+ def fetcher_names = @fetchers.keys
45
+ def reducer_names = @reducers.keys
46
+ def hook_events = @hooks.keys
47
+ end
48
+ end
@@ -0,0 +1,29 @@
1
+ module Textus
2
+ THREAD_REGISTRY_KEY = :__textus_active_registry__
3
+ private_constant :THREAD_REGISTRY_KEY
4
+
5
+ def self.with_registry(registry)
6
+ prev = Thread.current[THREAD_REGISTRY_KEY]
7
+ Thread.current[THREAD_REGISTRY_KEY] = registry
8
+ yield
9
+ ensure
10
+ Thread.current[THREAD_REGISTRY_KEY] = prev
11
+ end
12
+
13
+ def self.current_registry
14
+ Thread.current[THREAD_REGISTRY_KEY] or
15
+ raise UsageError.new("no active registry; extension code must be loaded by a Store")
16
+ end
17
+
18
+ def self.fetcher(name, &)
19
+ current_registry.register_fetcher(name, &)
20
+ end
21
+
22
+ def self.reducer(name, &)
23
+ current_registry.register_reducer(name, &)
24
+ end
25
+
26
+ def self.hook(event, name, &)
27
+ current_registry.register_hook(event, name, &)
28
+ end
29
+ end
@@ -0,0 +1,51 @@
1
+ require "fileutils"
2
+
3
+ module Textus
4
+ module Init
5
+ ZONES = %w[canon working intake pending derived].freeze
6
+
7
+ DEFAULT_MANIFEST = <<~YAML
8
+ version: textus/1
9
+ zones:
10
+ - { name: canon, writable_by: [human] }
11
+ - { name: working, writable_by: [human, ai, script] }
12
+ - { name: intake, writable_by: [script] }
13
+ - { name: pending, writable_by: [ai, human] }
14
+ - { name: derived, writable_by: [build] }
15
+ entries:
16
+ - { key: canon.identity, path: canon/identity.md, zone: canon, schema: null, owner: human:self }
17
+ - { key: working.notes, path: working/notes, zone: working, schema: null, owner: human:self, nested: true }
18
+ YAML
19
+
20
+ def self.run(target_root)
21
+ raise UsageError.new(".textus/ already exists at #{target_root}") if File.directory?(target_root)
22
+
23
+ FileUtils.mkdir_p(File.join(target_root, "schemas"))
24
+ FileUtils.mkdir_p(File.join(target_root, "templates"))
25
+ FileUtils.mkdir_p(File.join(target_root, "extensions"))
26
+ ZONES.each do |z|
27
+ dir = File.join(target_root, "zones", z)
28
+ FileUtils.mkdir_p(dir)
29
+ File.write(File.join(dir, ".gitkeep"), "")
30
+ end
31
+ File.write(File.join(target_root, "extensions", "README.md"), <<~MD)
32
+ # Extensions
33
+
34
+ Drop one Ruby file per extension. Three verbs are available:
35
+
36
+ ```ruby
37
+ Textus.fetcher(:name) { |config:, store:| ... }
38
+ Textus.reducer(:name) { |rows:, config:| ... }
39
+ Textus.hook(:event, :name) { |key:, envelope:, store:, **kw| ... }
40
+ ```
41
+
42
+ Events: :put, :delete, :refresh, :build, :accept.
43
+
44
+ See SPEC.md §5.11 for the full contract.
45
+ MD
46
+
47
+ File.write(File.join(target_root, "manifest.yaml"), DEFAULT_MANIFEST)
48
+ { "protocol" => PROTOCOL, "initialized" => target_root }
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,104 @@
1
+ module Textus
2
+ # Read-only "what's in this store and how do I use it" envelope.
3
+ # A single call gives an agent the working model of a textus-managed
4
+ # project: zones and their write authority, entries and their flags,
5
+ # registered extensions, write flows, and the CLI verb catalog.
6
+ #
7
+ # Intro is side-effect-free.
8
+ module Intro
9
+ PROTOCOL_ID = "textus/1".freeze
10
+
11
+ # Conventional zone purposes. Unknown zones (declared in the manifest
12
+ # but not listed here) get no `purpose` field.
13
+ ZONE_PURPOSES = {
14
+ "canon" => "slow-changing identity; human-only writes",
15
+ "working" => "active project state; humans, AI, and scripts share this surface",
16
+ "intake" => "declared external inputs; script-refreshed via fetchers",
17
+ "pending" => "AI proposals awaiting human accept",
18
+ "derived" => "build-computed outputs; never hand-edited",
19
+ }.freeze
20
+
21
+ WRITE_FLOWS = {
22
+ "human" => "edit files in canon/working zones, then 'textus put KEY --as=human'",
23
+ "ai" => "propose changes by writing 'pending.*' entries with --as=ai and a 'proposal:' frontmatter block; " \
24
+ "a human runs 'textus accept' to apply",
25
+ "script" => "refresh intake entries with 'textus refresh KEY --as=script' (uses the entry's declared fetcher)",
26
+ "build" => "'textus build' computes derived entries from projections; derived files are never hand-edited",
27
+ }.freeze
28
+
29
+ # The CLI verb catalog. Truth lives here; do not derive dynamically.
30
+ # Agents that read intro should see a stable shape regardless of how
31
+ # verb implementations evolve.
32
+ CLI_VERBS = [
33
+ { "name" => "intro", "summary" => "this output — orientation for agents and tools" },
34
+ { "name" => "list", "summary" => "enumerate keys (optional --prefix)" },
35
+ { "name" => "get", "summary" => "read an entry; envelope with frontmatter, body, uid, etag" },
36
+ { "name" => "where", "summary" => "resolve a key to its zone and path without reading" },
37
+ { "name" => "schema", "summary" => "field shape for a key family" },
38
+ { "name" => "put", "summary" => "write an entry; --as=<role>, --stdin payload" },
39
+ { "name" => "accept", "summary" => "apply a pending.* proposal; --as=human only" },
40
+ { "name" => "mv", "summary" => "rename a key in place; uid preserved, audit row written" },
41
+ { "name" => "delete", "summary" => "delete an entry; --as=<role>" },
42
+ { "name" => "build", "summary" => "materialize derived entries; publish_to and publish_each fan out copies" },
43
+ { "name" => "refresh", "summary" => "run a fetcher for an intake entry" },
44
+ { "name" => "stale", "summary" => "list derived/intake entries past their freshness check" },
45
+ { "name" => "doctor", "summary" => "health-check the store (missing schemas, illegal keys, sentinel drift, etc.)" },
46
+ { "name" => "migrate-keys", "summary" => "rename files whose basenames violate the strict key grammar" },
47
+ { "name" => "extensions", "summary" => "list registered reducers/fetchers/hooks" },
48
+ ].freeze
49
+
50
+ def self.run(store)
51
+ {
52
+ "protocol" => PROTOCOL_ID,
53
+ "store_root" => store.root,
54
+ "zones" => zones_for(store),
55
+ "entries" => entries_for(store),
56
+ "extensions" => extensions_for(store),
57
+ "write_flows" => WRITE_FLOWS.dup,
58
+ "cli_verbs" => CLI_VERBS.map(&:dup),
59
+ "docs" => { "spec" => "SPEC.md", "example" => "examples/claude-plugin/" },
60
+ }
61
+ end
62
+
63
+ def self.zones_for(store)
64
+ store.manifest.zones.map do |name, writers|
65
+ row = { "name" => name, "writers" => Array(writers) }
66
+ purpose = ZONE_PURPOSES[name]
67
+ row["purpose"] = purpose if purpose
68
+ row
69
+ end
70
+ end
71
+
72
+ def self.entries_for(store)
73
+ store.manifest.entries.map do |e|
74
+ derived = store.manifest.zone_writers(e.zone).include?("build")
75
+ {
76
+ "key" => e.key,
77
+ "zone" => e.zone,
78
+ "schema" => e.schema,
79
+ "nested" => e.nested ? true : false,
80
+ "owner" => e.owner,
81
+ "format" => e.format,
82
+ "derived" => derived,
83
+ "intake" => !e.fetcher.nil?,
84
+ "publish_to" => Array(e.publish_to),
85
+ "publish_each" => e.publish_each,
86
+ }
87
+ end
88
+ end
89
+
90
+ def self.extensions_for(store)
91
+ reg = store.registry
92
+ reducers = reg.reducer_names.map(&:to_s).sort
93
+ fetchers = reg.fetcher_names.map(&:to_s).sort
94
+ hooks = reg.hook_events.flat_map do |evt|
95
+ reg.hooks(evt).map { |h| { "event" => evt.to_s, "name" => h[:name].to_s } }
96
+ end.sort_by { |h| [h["event"], h["name"]] }
97
+ {
98
+ "reducers" => reducers,
99
+ "fetchers" => fetchers,
100
+ "hooks" => hooks,
101
+ }
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,53 @@
1
+ module Textus
2
+ # Small utilities for ranking key suggestions. Bounded inputs only —
3
+ # Levenshtein is O(n*m) so we refuse to compute on long strings.
4
+ module KeyDistance
5
+ MAX_LEN = 200
6
+
7
+ # Length of the shared dot-separated prefix between two dotted keys.
8
+ def self.shared_prefix_segments(left, right)
9
+ asegs = left.split(".")
10
+ bsegs = right.split(".")
11
+ n = [asegs.length, bsegs.length].min
12
+ i = 0
13
+ i += 1 while i < n && asegs[i] == bsegs[i]
14
+ i
15
+ end
16
+
17
+ # Classic iterative Levenshtein with two rows. Bounded to MAX_LEN.
18
+ def self.levenshtein(left, right)
19
+ return nil if left.length > MAX_LEN || right.length > MAX_LEN
20
+ return right.length if left.empty?
21
+ return left.length if right.empty?
22
+
23
+ prev = (0..right.length).to_a
24
+ curr = Array.new(right.length + 1, 0)
25
+ (1..left.length).each do |i|
26
+ curr[0] = i
27
+ (1..right.length).each do |j|
28
+ cost = left[i - 1] == right[j - 1] ? 0 : 1
29
+ curr[j] = [
30
+ curr[j - 1] + 1, # insertion
31
+ prev[j] + 1, # deletion
32
+ prev[j - 1] + cost, # substitution
33
+ ].min
34
+ end
35
+ prev, curr = curr, prev
36
+ end
37
+ prev[right.length]
38
+ end
39
+
40
+ # Rank candidate keys against requested. Returns up to `limit` keys.
41
+ # Sort: longer shared prefix first; then smaller Levenshtein distance.
42
+ def self.suggest(requested, candidates, limit: 5)
43
+ return [] if requested.nil? || requested.empty?
44
+
45
+ scored = candidates.first(200).map do |k|
46
+ prefix = shared_prefix_segments(requested, k)
47
+ dist = levenshtein(requested, k) || Float::INFINITY
48
+ [k, prefix, dist]
49
+ end
50
+ scored.sort_by { |(_, prefix, dist)| [-prefix, dist] }.first(limit).map(&:first)
51
+ end
52
+ end
53
+ end