textus 0.4.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +147 -2
  3. data/README.md +38 -28
  4. data/SPEC.md +84 -147
  5. data/docs/architecture.md +82 -28
  6. data/lib/textus/builder/pipeline.rb +56 -0
  7. data/lib/textus/builder/renderer/json.rb +42 -0
  8. data/lib/textus/builder/renderer/markdown.rb +22 -0
  9. data/lib/textus/builder/renderer/text.rb +14 -0
  10. data/lib/textus/builder/renderer/yaml.rb +42 -0
  11. data/lib/textus/builder/renderer.rb +17 -0
  12. data/lib/textus/builder.rb +9 -114
  13. data/lib/textus/cli/group/hook.rb +11 -0
  14. data/lib/textus/cli/group/key.rb +12 -0
  15. data/lib/textus/cli/group/schema.rb +13 -0
  16. data/lib/textus/cli/group.rb +51 -0
  17. data/lib/textus/cli/verb/accept.rb +15 -0
  18. data/lib/textus/cli/verb/build.rb +13 -0
  19. data/lib/textus/cli/verb/delete.rb +16 -0
  20. data/lib/textus/cli/verb/deps.rb +12 -0
  21. data/lib/textus/cli/verb/doctor.rb +15 -0
  22. data/lib/textus/cli/verb/get.rb +12 -0
  23. data/lib/textus/cli/verb/hook_run.rb +48 -0
  24. data/lib/textus/cli/verb/hooks.rb +50 -0
  25. data/lib/textus/cli/verb/init.rb +14 -0
  26. data/lib/textus/cli/verb/intro.rb +11 -0
  27. data/lib/textus/cli/verb/list.rb +14 -0
  28. data/lib/textus/cli/verb/migrate_keys.rb +16 -0
  29. data/lib/textus/cli/verb/mv.rb +17 -0
  30. data/lib/textus/cli/verb/published.rb +11 -0
  31. data/lib/textus/cli/verb/put.rb +50 -0
  32. data/lib/textus/cli/verb/rdeps.rb +12 -0
  33. data/lib/textus/cli/verb/refresh.rb +15 -0
  34. data/lib/textus/cli/verb/schema.rb +12 -0
  35. data/lib/textus/cli/verb/schema_diff.rb +12 -0
  36. data/lib/textus/cli/verb/schema_init.rb +16 -0
  37. data/lib/textus/cli/verb/schema_migrate.rb +16 -0
  38. data/lib/textus/cli/verb/stale.rb +14 -0
  39. data/lib/textus/cli/verb/uid.rb +12 -0
  40. data/lib/textus/cli/verb/where.rb +12 -0
  41. data/lib/textus/cli/verb.rb +62 -0
  42. data/lib/textus/cli.rb +44 -385
  43. data/lib/textus/doctor/check/audit_log.rb +50 -0
  44. data/lib/textus/doctor/check/hooks.rb +29 -0
  45. data/lib/textus/doctor/check/illegal_keys.rb +49 -0
  46. data/lib/textus/doctor/check/manifest_files.rb +38 -0
  47. data/lib/textus/doctor/check/schema_violations.rb +22 -0
  48. data/lib/textus/doctor/check/schemas.rb +26 -0
  49. data/lib/textus/doctor/check/sentinels.rb +57 -0
  50. data/lib/textus/doctor/check/templates.rb +26 -0
  51. data/lib/textus/doctor/check/unowned_schema_fields.rb +34 -0
  52. data/lib/textus/doctor/check.rb +30 -0
  53. data/lib/textus/doctor.rb +29 -264
  54. data/lib/textus/entry/base.rb +30 -0
  55. data/lib/textus/entry/json.rb +11 -5
  56. data/lib/textus/entry/markdown.rb +5 -5
  57. data/lib/textus/entry/text.rb +4 -4
  58. data/lib/textus/entry/yaml.rb +11 -5
  59. data/lib/textus/entry.rb +2 -7
  60. data/lib/textus/envelope.rb +30 -0
  61. data/lib/textus/errors.rb +2 -2
  62. data/lib/textus/hooks/builtin.rb +70 -0
  63. data/lib/textus/hooks/dispatcher.rb +49 -0
  64. data/lib/textus/hooks/loader.rb +26 -0
  65. data/lib/textus/hooks/registry.rb +73 -0
  66. data/lib/textus/init.rb +14 -11
  67. data/lib/textus/intro.rb +16 -18
  68. data/lib/textus/key/distance.rb +55 -0
  69. data/lib/textus/key/grammar.rb +33 -0
  70. data/lib/textus/key/path.rb +17 -0
  71. data/lib/textus/manifest/entry.rb +199 -0
  72. data/lib/textus/manifest.rb +20 -254
  73. data/lib/textus/migrate_keys.rb +1 -1
  74. data/lib/textus/projection.rb +6 -5
  75. data/lib/textus/proposal.rb +4 -4
  76. data/lib/textus/refresh.rb +17 -17
  77. data/lib/textus/schema/tools.rb +89 -0
  78. data/lib/textus/store/audit_log.rb +71 -0
  79. data/lib/textus/store/mover.rb +121 -0
  80. data/lib/textus/store/reader.rb +67 -0
  81. data/lib/textus/store/staleness.rb +133 -0
  82. data/lib/textus/store/validator.rb +56 -0
  83. data/lib/textus/store/view.rb +29 -0
  84. data/lib/textus/store/writer.rb +132 -0
  85. data/lib/textus/store.rb +26 -527
  86. data/lib/textus/version.rb +2 -2
  87. data/lib/textus.rb +14 -29
  88. metadata +78 -8
  89. data/lib/textus/audit_log.rb +0 -32
  90. data/lib/textus/builtin_actions.rb +0 -68
  91. data/lib/textus/extension_registry.rb +0 -61
  92. data/lib/textus/extensions.rb +0 -33
  93. data/lib/textus/key_distance.rb +0 -53
  94. data/lib/textus/schema_tools.rb +0 -87
  95. data/lib/textus/store_view.rb +0 -27
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Envelope
5
+ # rubocop:disable Metrics/ParameterLists
6
+ def self.build(key:, mentry:, path:, meta:, body:, etag:, content: nil)
7
+ # rubocop:enable Metrics/ParameterLists
8
+ env = {
9
+ "protocol" => PROTOCOL,
10
+ "key" => key,
11
+ "zone" => mentry.zone,
12
+ "owner" => mentry.owner,
13
+ "path" => path,
14
+ "format" => mentry.format,
15
+ "_meta" => meta,
16
+ "body" => body,
17
+ "etag" => etag,
18
+ "schema_ref" => mentry.schema,
19
+ "uid" => extract_uid(meta),
20
+ }
21
+ env["content"] = content unless content.nil?
22
+ env
23
+ end
24
+
25
+ def self.extract_uid(meta)
26
+ v = meta.is_a?(Hash) ? meta["uid"] : nil
27
+ v.is_a?(String) ? v : nil
28
+ end
29
+ end
30
+ end
data/lib/textus/errors.rb CHANGED
@@ -51,10 +51,10 @@ module Textus
51
51
  private
52
52
 
53
53
  def default_hint_for(path, m)
54
- if m.is_a?(String) && (match = m.match(/frontmatter name '([^']+)' does not match basename '([^']+)'/))
54
+ if m.is_a?(String) && (match = m.match(/name '([^']+)' does not match basename '([^']+)'/))
55
55
  name, basename = match.captures
56
56
  ext = File.extname(path)
57
- "rename the file to '#{name}#{ext}' or change frontmatter name: to '#{basename}'"
57
+ "rename the file to '#{name}#{ext}' or change _meta.name to '#{basename}'"
58
58
  else
59
59
  "open #{path} and check the YAML frontmatter for syntax errors"
60
60
  end
@@ -0,0 +1,70 @@
1
+ require "json"
2
+ require "csv"
3
+ require "yaml"
4
+ require "rexml/document"
5
+
6
+ module Textus
7
+ module Hooks
8
+ module Builtin
9
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
10
+ def self.register_all
11
+ Textus.hook(:fetch, :json) do |store:, config:, args:|
12
+ _ = store
13
+ _ = args
14
+ data = JSON.parse(config["bytes"].to_s)
15
+ { _meta: {}, body: YAML.dump(data) }
16
+ end
17
+
18
+ Textus.hook(:fetch, :csv) do |store:, config:, args:|
19
+ _ = store
20
+ _ = args
21
+ rows = CSV.parse(config["bytes"].to_s, headers: true).map(&:to_h)
22
+ { _meta: {}, body: YAML.dump(rows) }
23
+ end
24
+
25
+ Textus.hook(:fetch, :"markdown-links") do |store:, config:, args:|
26
+ _ = store
27
+ _ = args
28
+ links = config["bytes"].to_s.scan(%r{\[([^\]]+)\]\((https?://[^)\s]+)\)}).map do |text, href|
29
+ { "text" => text, "href" => href }
30
+ end
31
+ { _meta: {}, body: YAML.dump(links) }
32
+ end
33
+
34
+ Textus.hook(:fetch, :"ical-events") do |store:, config:, args:|
35
+ _ = store
36
+ _ = args
37
+ events = []
38
+ current = nil
39
+ config["bytes"].to_s.each_line do |line|
40
+ line = line.strip
41
+ case line
42
+ when "BEGIN:VEVENT" then current = {}
43
+ when "END:VEVENT"
44
+ events << current if current
45
+ current = nil
46
+ when /\A(SUMMARY|DTSTART|DTEND|UID|LOCATION|DESCRIPTION):(.*)\z/
47
+ current[Regexp.last_match(1).downcase] = Regexp.last_match(2) if current
48
+ end
49
+ end
50
+ { _meta: {}, body: YAML.dump(events) }
51
+ end
52
+
53
+ Textus.hook(:fetch, :rss) do |store:, config:, args:|
54
+ _ = store
55
+ _ = args
56
+ doc = REXML::Document.new(config["bytes"].to_s)
57
+ items = doc.elements.to_a("//item").map do |item|
58
+ {
59
+ "title" => item.elements["title"]&.text,
60
+ "link" => item.elements["link"]&.text,
61
+ "pubDate" => item.elements["pubDate"]&.text,
62
+ }
63
+ end
64
+ { _meta: {}, body: YAML.dump(items) }
65
+ end
66
+ end
67
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module Textus
6
+ module Hooks
7
+ class Dispatcher
8
+ HOOK_TIMEOUT_SECONDS = 2
9
+
10
+ def initialize(audit_log:)
11
+ @audit_log = audit_log
12
+ @subscribers = Hash.new { |h, k| h[k] = [] }
13
+ end
14
+
15
+ def subscribe(event, name, keys: nil, &block)
16
+ @subscribers[event.to_sym] << { name: name.to_sym, callable: block, keys: keys }
17
+ end
18
+
19
+ def publish(event, **kwargs)
20
+ key = kwargs[:key] || "-"
21
+ @subscribers[event.to_sym].each do |sub|
22
+ next unless match?(sub[:keys], key)
23
+
24
+ invoke(event, sub, key, kwargs)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def invoke(event, sub, key, kwargs)
31
+ Timeout.timeout(HOOK_TIMEOUT_SECONDS) { sub[:callable].call(**kwargs) }
32
+ rescue StandardError => e
33
+ extras = { "event" => event.to_s, "hook" => sub[:name].to_s, "error" => "#{e.class}: #{e.message}" }
34
+ extras["target_key"] = kwargs[:target_key] if kwargs.key?(:target_key)
35
+ extras["pending_key"] = kwargs[:pending_key] if kwargs.key?(:pending_key)
36
+ @audit_log.append(
37
+ role: "script", verb: "event_error", key: key,
38
+ etag_before: nil, etag_after: nil, extras: extras
39
+ )
40
+ end
41
+
42
+ def match?(globs, key)
43
+ return true if globs.nil?
44
+
45
+ Array(globs).any? { |g| File.fnmatch?(g, key.to_s, File::FNM_PATHNAME) }
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,26 @@
1
+ module Textus
2
+ module Hooks
3
+ module Loader
4
+ THREAD_REGISTRY_KEY = :__textus_active_registry__
5
+ private_constant :THREAD_REGISTRY_KEY
6
+
7
+ def self.with_registry(registry)
8
+ prev = Thread.current[THREAD_REGISTRY_KEY]
9
+ Thread.current[THREAD_REGISTRY_KEY] = registry
10
+ yield
11
+ ensure
12
+ Thread.current[THREAD_REGISTRY_KEY] = prev
13
+ end
14
+
15
+ def self.current_registry
16
+ Thread.current[THREAD_REGISTRY_KEY] or
17
+ raise UsageError.new("no active registry; hook code must be loaded by a Store")
18
+ end
19
+ end
20
+ end
21
+
22
+ # Public DSL — unchanged surface
23
+ def self.with_registry(registry, &) = Hooks::Loader.with_registry(registry, &)
24
+ def self.current_registry = Hooks::Loader.current_registry
25
+ def self.hook(event, name, **, &) = Hooks::Loader.current_registry.register(event, name, **, &)
26
+ end
@@ -0,0 +1,73 @@
1
+ module Textus
2
+ module Hooks
3
+ class Registry
4
+ EVENTS = {
5
+ # RPC: exactly 1 handler per name; return value flows into store; failure aborts.
6
+ fetch: { mode: :rpc, args: %i[store config args] },
7
+ reduce: { mode: :rpc, args: %i[store rows config] },
8
+ check: { mode: :rpc, args: %i[store] },
9
+
10
+ # Pub-sub: 0..N handlers per event; return discarded; failure logged to audit.
11
+ put: { mode: :pubsub, args: %i[store key envelope] },
12
+ delete: { mode: :pubsub, args: %i[store key] },
13
+ refresh: { mode: :pubsub, args: %i[store key envelope change] },
14
+ build: { mode: :pubsub, args: %i[store key envelope sources] },
15
+ accept: { mode: :pubsub, args: %i[store key target_key] },
16
+ }.freeze
17
+
18
+ def initialize(dispatcher: nil)
19
+ @rpc = Hash.new { |h, k| h[k] = {} } # event => { name => callable }
20
+ @pubsub = Hash.new { |h, k| h[k] = [] } # event => [{name:, callable:, keys:}]
21
+ @dispatcher = dispatcher
22
+ end
23
+
24
+ def register(event, name, keys: nil, &blk)
25
+ spec = EVENTS[event.to_sym] or raise UsageError.new("unknown event: #{event}")
26
+ shape_check!(event, spec, blk)
27
+ name = name.to_sym
28
+
29
+ case spec[:mode]
30
+ when :rpc
31
+ raise UsageError.new("#{event} '#{name}' already registered") if @rpc[event.to_sym].key?(name)
32
+
33
+ @rpc[event.to_sym][name] = blk
34
+ when :pubsub
35
+ raise UsageError.new("#{event} hook '#{name}' already registered") if @pubsub[event.to_sym].any? { |h| h[:name] == name }
36
+
37
+ @pubsub[event.to_sym] << { name: name, callable: blk, keys: keys }
38
+ @dispatcher&.subscribe(event, name, keys: keys, &blk)
39
+ end
40
+ end
41
+
42
+ def rpc_callable(event, name)
43
+ @rpc[event.to_sym][name.to_sym] or
44
+ raise UsageError.new("unknown #{event}: #{name}")
45
+ end
46
+
47
+ def listeners(event, key:)
48
+ @pubsub[event.to_sym].select { |h| h[:keys].nil? || matches_any?(h[:keys], key) }
49
+ end
50
+
51
+ def rpc_names(event) = @rpc[event.to_sym].keys
52
+ def pubsub_handlers(event) = @pubsub[event.to_sym]
53
+
54
+ private
55
+
56
+ def shape_check!(event, spec, blk)
57
+ required = spec[:args]
58
+ provided = blk.parameters.select { |t, _| %i[keyreq key keyrest].include?(t) } # rubocop:disable Style/HashSlice
59
+ keyrest = provided.any? { |t, _| t == :keyrest }
60
+ missing = required - provided.map { |_, n| n }
61
+ return if keyrest || missing.empty?
62
+
63
+ raise UsageError.new(
64
+ "#{event} hooks must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})",
65
+ )
66
+ end
67
+
68
+ def matches_any?(globs, key)
69
+ Array(globs).any? { |g| File.fnmatch?(g, key.to_s, File::FNM_PATHNAME) }
70
+ end
71
+ end
72
+ end
73
+ end
data/lib/textus/init.rb CHANGED
@@ -5,7 +5,7 @@ module Textus
5
5
  ZONES = %w[canon working intake pending derived].freeze
6
6
 
7
7
  DEFAULT_MANIFEST = <<~YAML
8
- version: textus/1
8
+ version: textus/2
9
9
  zones:
10
10
  - { name: canon, writable_by: [human] }
11
11
  - { name: working, writable_by: [human, ai, script] }
@@ -22,27 +22,30 @@ module Textus
22
22
 
23
23
  FileUtils.mkdir_p(File.join(target_root, "schemas"))
24
24
  FileUtils.mkdir_p(File.join(target_root, "templates"))
25
- FileUtils.mkdir_p(File.join(target_root, "extensions"))
25
+ FileUtils.mkdir_p(File.join(target_root, "hooks"))
26
26
  ZONES.each do |z|
27
27
  dir = File.join(target_root, "zones", z)
28
28
  FileUtils.mkdir_p(dir)
29
29
  File.write(File.join(dir, ".gitkeep"), "")
30
30
  end
31
- File.write(File.join(target_root, "extensions", "README.md"), <<~MD)
32
- # Extensions
31
+ File.write(File.join(target_root, "hooks", "README.md"), <<~MD)
32
+ # Hooks
33
33
 
34
- Drop one Ruby file per extension. Four verbs are available:
34
+ Drop one Ruby file per hook. All extensions register through one DSL.
35
+ Every handler receives `store:` as its first kwarg, then event-specific args.
35
36
 
36
37
  ```ruby
37
- Textus.action(:name) { |config:, store:, args:| ... }
38
- Textus.reducer(:name) { |rows:, config:| ... }
39
- Textus.hook(:event, :name) { |key:, envelope:, **kw| ... }
40
- Textus.doctor_check(:name) { |store:| ... }
38
+ Textus.hook(:fetch, :name) { |store:, config:, args:| ... } # bring bytes in
39
+ Textus.hook(:reduce, :name) { |store:, rows:, config:| ... } # transform rows
40
+ Textus.hook(:check, :name) { |store:| ... } # doctor check
41
+ Textus.hook(:put, :name, keys: ["..."]) # lifecycle listener
42
+ { |store:, key:, envelope:| ... }
41
43
  ```
42
44
 
43
- Events: :put, :delete, :refresh, :build, :accept.
45
+ Events: :fetch, :reduce, :check (rpc — return value used)
46
+ :put, :delete, :refresh, :build, :accept (pub-sub — return discarded)
44
47
 
45
- See SPEC.md §5.11 for the full contract.
48
+ See SPEC.md §5.10 for the full table.
46
49
  MD
47
50
 
48
51
  File.write(File.join(target_root, "manifest.yaml"), DEFAULT_MANIFEST)
data/lib/textus/intro.rb CHANGED
@@ -6,7 +6,7 @@ module Textus
6
6
  #
7
7
  # Intro is side-effect-free.
8
8
  module Intro
9
- PROTOCOL_ID = "textus/1".freeze
9
+ PROTOCOL_ID = PROTOCOL
10
10
 
11
11
  # Conventional zone purposes. Unknown zones (declared in the manifest
12
12
  # but not listed here) get no `purpose` field.
@@ -32,19 +32,19 @@ module Textus
32
32
  CLI_VERBS = [
33
33
  { "name" => "intro", "summary" => "this output — orientation for agents and tools" },
34
34
  { "name" => "list", "summary" => "enumerate keys (optional --prefix)" },
35
- { "name" => "get", "summary" => "read an entry; envelope with frontmatter, body, uid, etag" },
35
+ { "name" => "get", "summary" => "read an entry; envelope with _meta, body, uid, etag" },
36
36
  { "name" => "where", "summary" => "resolve a key to its zone and path without reading" },
37
37
  { "name" => "schema", "summary" => "field shape for a key family" },
38
38
  { "name" => "put", "summary" => "write an entry; --as=<role>, --stdin payload" },
39
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" },
40
+ { "name" => "key", "summary" => "key operations: 'key mv', 'key uid', 'key migrate'" },
41
41
  { "name" => "delete", "summary" => "delete an entry; --as=<role>" },
42
42
  { "name" => "build", "summary" => "materialize derived entries; publish_to and publish_each fan out copies" },
43
43
  { "name" => "refresh", "summary" => "run an action for an intake entry" },
44
44
  { "name" => "stale", "summary" => "list derived/intake entries past their freshness check" },
45
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 actions, reducers, doctor_checks, declared hooks" },
46
+ { "name" => "hook",
47
+ "summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
48
48
  ].freeze
49
49
 
50
50
  def self.run(store)
@@ -80,7 +80,7 @@ module Textus
80
80
  "owner" => e.owner,
81
81
  "format" => e.format,
82
82
  "derived" => derived,
83
- "intake" => !e.action.nil?,
83
+ "intake" => !e.fetch.nil?,
84
84
  "publish_to" => Array(e.publish_to),
85
85
  "publish_each" => e.publish_each,
86
86
  }
@@ -89,18 +89,16 @@ module Textus
89
89
 
90
90
  def self.extensions_for(store)
91
91
  reg = store.registry
92
- reducers = reg.reducer_names.map(&:to_s).sort
93
- actions = reg.action_names.map(&:to_s).sort
94
- doctor_checks = reg.doctor_check_names.map(&:to_s).sort
95
- hooks = reg.hook_events.flat_map do |evt|
96
- reg.hooks(evt).map { |h| { "event" => evt.to_s, "name" => h[:name].to_s } }
97
- end.sort_by { |h| [h["event"], h["name"]] }
98
- {
99
- "reducers" => reducers,
100
- "actions" => actions,
101
- "doctor_checks" => doctor_checks,
102
- "hooks" => hooks,
103
- }
92
+ sections = {}
93
+ Hooks::Registry::EVENTS.each do |event, spec|
94
+ case spec[:mode]
95
+ when :rpc
96
+ sections[event.to_s] = reg.rpc_names(event).map(&:to_s).sort
97
+ when :pubsub
98
+ sections[event.to_s] = reg.pubsub_handlers(event).map { |h| h[:name].to_s }.sort
99
+ end
100
+ end
101
+ sections
104
102
  end
105
103
  end
106
104
  end
@@ -0,0 +1,55 @@
1
+ module Textus
2
+ module Key
3
+ # Small utilities for ranking key suggestions. Bounded inputs only —
4
+ # Levenshtein is O(n*m) so we refuse to compute on long strings.
5
+ class Distance
6
+ MAX_LEN = 200
7
+
8
+ # Length of the shared dot-separated prefix between two dotted keys.
9
+ def self.shared_prefix_segments(left, right)
10
+ asegs = left.split(".")
11
+ bsegs = right.split(".")
12
+ n = [asegs.length, bsegs.length].min
13
+ i = 0
14
+ i += 1 while i < n && asegs[i] == bsegs[i]
15
+ i
16
+ end
17
+
18
+ # Classic iterative Levenshtein with two rows. Bounded to MAX_LEN.
19
+ def self.levenshtein(left, right)
20
+ return nil if left.length > MAX_LEN || right.length > MAX_LEN
21
+ return right.length if left.empty?
22
+ return left.length if right.empty?
23
+
24
+ prev = (0..right.length).to_a
25
+ curr = Array.new(right.length + 1, 0)
26
+ (1..left.length).each do |i|
27
+ curr[0] = i
28
+ (1..right.length).each do |j|
29
+ cost = left[i - 1] == right[j - 1] ? 0 : 1
30
+ curr[j] = [
31
+ curr[j - 1] + 1, # insertion
32
+ prev[j] + 1, # deletion
33
+ prev[j - 1] + cost, # substitution
34
+ ].min
35
+ end
36
+ prev, curr = curr, prev
37
+ end
38
+ prev[right.length]
39
+ end
40
+
41
+ # Rank candidate keys against requested. Returns up to `limit` keys.
42
+ # Sort: longer shared prefix first; then smaller Levenshtein distance.
43
+ def self.suggest(requested, candidates, limit: 5)
44
+ return [] if requested.nil? || requested.empty?
45
+
46
+ scored = candidates.first(200).map do |k|
47
+ prefix = shared_prefix_segments(requested, k)
48
+ dist = levenshtein(requested, k) || Float::INFINITY
49
+ [k, prefix, dist]
50
+ end
51
+ scored.sort_by { |(_, prefix, dist)| [-prefix, dist] }.first(limit).map(&:first)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,33 @@
1
+ module Textus
2
+ module Key
3
+ module Grammar
4
+ SEGMENT = /\A[a-z0-9][a-z0-9-]*\z/
5
+ MAX_SEGMENTS = 8
6
+ MAX_SEGMENT_LEN = 64
7
+
8
+ module_function
9
+
10
+ def validate!(key) # rubocop:disable Naming/PredicateMethod
11
+ raise UsageError.new("key must be a String") unless key.is_a?(String)
12
+ raise UsageError.new("empty key") if key.empty?
13
+
14
+ segs = key.split(".")
15
+ raise UsageError.new("key '#{key}' has #{segs.length} segments (max #{MAX_SEGMENTS})") if segs.length > MAX_SEGMENTS
16
+
17
+ segs.each do |seg|
18
+ if seg.empty?
19
+ raise UsageError.new("empty segment in key '#{key}'")
20
+ elsif seg.length > MAX_SEGMENT_LEN
21
+ raise UsageError.new("segment '#{seg}' in key '#{key}' exceeds #{MAX_SEGMENT_LEN} chars")
22
+ elsif !seg.match?(SEGMENT)
23
+ raise UsageError.new(
24
+ "invalid key segment '#{seg}' in '#{key}': must match [a-z0-9][a-z0-9-]* " \
25
+ "(lowercase, digits, hyphens; no underscores or uppercase)",
26
+ )
27
+ end
28
+ end
29
+ true
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,17 @@
1
+ module Textus
2
+ module Key
3
+ module Path
4
+ # Returns the absolute filesystem path for a manifest entry (the leaf file,
5
+ # not a nested directory). Adds the format's primary extension when the
6
+ # manifest entry's `path:` is extensionless.
7
+ def self.resolve(manifest, mentry)
8
+ primary_ext = Entry.for_format(mentry.format).extensions.first
9
+ if File.extname(mentry.path) == ""
10
+ File.join(manifest.root, "zones", mentry.path + primary_ext)
11
+ else
12
+ File.join(manifest.root, "zones", mentry.path)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end