coradoc 2.0.27 → 2.0.29

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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/lib/coradoc/cli.rb +9 -6
  3. data/lib/coradoc/configurable.rb +6 -3
  4. data/lib/coradoc/coradoc.rb +5 -2
  5. data/lib/coradoc/core_model/attribute_reference_resolver.rb +19 -7
  6. data/lib/coradoc/core_model/base.rb +45 -2
  7. data/lib/coradoc/core_model/comment_line.rb +1 -1
  8. data/lib/coradoc/core_model/definition_item.rb +22 -1
  9. data/lib/coradoc/core_model/horizontal_rule_block.rb +4 -0
  10. data/lib/coradoc/core_model/include_level_offset.rb +1 -1
  11. data/lib/coradoc/core_model/include_options.rb +1 -0
  12. data/lib/coradoc/core_model/inline_content.rb +1 -0
  13. data/lib/coradoc/core_model/list_block.rb +1 -1
  14. data/lib/coradoc/core_model/listing_block.rb +4 -0
  15. data/lib/coradoc/core_model/literal_block.rb +4 -0
  16. data/lib/coradoc/core_model/pass_block.rb +4 -0
  17. data/lib/coradoc/core_model/source_block.rb +4 -0
  18. data/lib/coradoc/core_model/stem_block.rb +4 -0
  19. data/lib/coradoc/dispatch.rb +1 -0
  20. data/lib/coradoc/errors.rb +19 -0
  21. data/lib/coradoc/format_catalog.rb +19 -0
  22. data/lib/coradoc/format_module.rb +10 -0
  23. data/lib/coradoc/hooks.rb +1 -1
  24. data/lib/coradoc/include_selectors/level_offset.rb +0 -2
  25. data/lib/coradoc/include_selectors/lines.rb +2 -2
  26. data/lib/coradoc/include_selectors/tags.rb +2 -4
  27. data/lib/coradoc/introspection/element_counter.rb +2 -2
  28. data/lib/coradoc/link_rewriter/visitor.rb +5 -14
  29. data/lib/coradoc/pipeline.rb +64 -5
  30. data/lib/coradoc/query.rb +8 -8
  31. data/lib/coradoc/reference/address/anchor.rb +48 -0
  32. data/lib/coradoc/reference/address/doi.rb +40 -0
  33. data/lib/coradoc/reference/address/isbn.rb +39 -0
  34. data/lib/coradoc/reference/address/path.rb +66 -0
  35. data/lib/coradoc/reference/address/scoped_path.rb +47 -0
  36. data/lib/coradoc/reference/address/url.rb +41 -0
  37. data/lib/coradoc/reference/address.rb +153 -0
  38. data/lib/coradoc/reference/catalog/composite.rb +59 -0
  39. data/lib/coradoc/reference/catalog/local.rb +97 -0
  40. data/lib/coradoc/reference/catalog/memory_index.rb +59 -0
  41. data/lib/coradoc/reference/catalog.rb +33 -0
  42. data/lib/coradoc/reference/edge/citation_options.rb +16 -0
  43. data/lib/coradoc/reference/edge/footnote_ref_options.rb +13 -0
  44. data/lib/coradoc/reference/edge/image_ref_options.rb +15 -0
  45. data/lib/coradoc/reference/edge/include_options.rb +15 -0
  46. data/lib/coradoc/reference/edge/kind.rb +72 -0
  47. data/lib/coradoc/reference/edge/link_options.rb +12 -0
  48. data/lib/coradoc/reference/edge/navigation_options.rb +12 -0
  49. data/lib/coradoc/reference/edge/options.rb +13 -0
  50. data/lib/coradoc/reference/edge.rb +80 -0
  51. data/lib/coradoc/reference/edge_search.rb +128 -0
  52. data/lib/coradoc/reference/materializer/base.rb +44 -0
  53. data/lib/coradoc/reference/materializer/passthrough.rb +31 -0
  54. data/lib/coradoc/reference/materializer/registry.rb +116 -0
  55. data/lib/coradoc/reference/materializer.rb +14 -0
  56. data/lib/coradoc/reference/presentation/base.rb +47 -0
  57. data/lib/coradoc/reference/presentation/custom_hierarchy.rb +104 -0
  58. data/lib/coradoc/reference/presentation/page.rb +20 -0
  59. data/lib/coradoc/reference/presentation/single_document.rb +35 -0
  60. data/lib/coradoc/reference/presentation/split_pages.rb +118 -0
  61. data/lib/coradoc/reference/presentation.rb +16 -0
  62. data/lib/coradoc/reference/resolution.rb +184 -0
  63. data/lib/coradoc/reference/resolver/base.rb +15 -0
  64. data/lib/coradoc/reference/resolver/caching.rb +37 -0
  65. data/lib/coradoc/reference/resolver/catalog.rb +69 -0
  66. data/lib/coradoc/reference/resolver/chain.rb +33 -0
  67. data/lib/coradoc/reference/resolver.rb +20 -0
  68. data/lib/coradoc/reference/result/ambiguous.rb +23 -0
  69. data/lib/coradoc/reference/result/base.rb +46 -0
  70. data/lib/coradoc/reference/result/missing.rb +14 -0
  71. data/lib/coradoc/reference/result/resolved.rb +22 -0
  72. data/lib/coradoc/reference/result.rb +20 -0
  73. data/lib/coradoc/reference.rb +65 -0
  74. data/lib/coradoc/registry.rb +6 -6
  75. data/lib/coradoc/resolve_includes.rb +2 -2
  76. data/lib/coradoc/validation.rb +44 -4
  77. data/lib/coradoc/version.rb +1 -1
  78. data/lib/coradoc.rb +1 -1
  79. metadata +45 -1
data/lib/coradoc/query.rb CHANGED
@@ -234,8 +234,8 @@ module Coradoc
234
234
  @elements = Array(elements).compact
235
235
  end
236
236
 
237
- def each(&block)
238
- @elements.each(&block)
237
+ def each(&)
238
+ @elements.each(&)
239
239
  end
240
240
 
241
241
  def [](index)
@@ -272,16 +272,16 @@ module Coradoc
272
272
  ResultSet.new(results.uniq)
273
273
  end
274
274
 
275
- def map(&block)
276
- ResultSet.new(@elements.map(&block))
275
+ def map(&)
276
+ ResultSet.new(@elements.map(&))
277
277
  end
278
278
 
279
- def select(&block)
280
- ResultSet.new(@elements.select(&block))
279
+ def select(&)
280
+ ResultSet.new(@elements.select(&))
281
281
  end
282
282
 
283
- def reject(&block)
284
- ResultSet.new(@elements.reject(&block))
283
+ def reject(&)
284
+ ResultSet.new(@elements.reject(&))
285
285
  end
286
286
 
287
287
  def to_a
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ class Address < Lutaml::Model::Serializable
6
+ # In-document anchor — the most common navigation target.
7
+ # Matches raw strings starting with "#" plus bareword names that
8
+ # don't look like document IDs or external URLs.
9
+ #
10
+ # "#intro" => anchor target "intro"
11
+ # "intro" => anchor target "intro"
12
+ # "footnote-1" => anchor target "footnote-1"
13
+ module Anchor
14
+ module_function
15
+
16
+ def scheme_name
17
+ :anchor
18
+ end
19
+
20
+ def matches?(raw)
21
+ return false if raw.nil? || raw.empty?
22
+
23
+ stripped = raw.to_s
24
+ return false if stripped == '#'
25
+ return true if stripped.start_with?('#')
26
+
27
+ stripped.match?(/\A[\w.\-]+\z/)
28
+ end
29
+
30
+ def parse(raw)
31
+ target = raw.to_s.delete_prefix('#')
32
+ new_address(target: target)
33
+ end
34
+
35
+ def serialize(address)
36
+ target = address.target
37
+ return '' if target.nil? || target.empty?
38
+
39
+ "##{target}"
40
+ end
41
+
42
+ def new_address(target:)
43
+ Address.new(scheme: 'anchor', target: target)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ class Address < Lutaml::Model::Serializable
6
+ # DOI — Digital Object Identifier. Regex per the DOI handbook.
7
+ module Doi
8
+ module_function
9
+
10
+ DOI_PATTERN = %r{\A(10\.\d{4,}/[^\s#]+)(?:#(.*))?\z}
11
+
12
+ def scheme_name
13
+ :doi
14
+ end
15
+
16
+ def matches?(raw)
17
+ return false if raw.nil? || raw.empty?
18
+
19
+ DOI_PATTERN.match?(raw.to_s)
20
+ end
21
+
22
+ def parse(raw)
23
+ match = DOI_PATTERN.match(raw.to_s)
24
+ raise Address::ParseError, "Invalid DOI: #{raw.inspect}" unless match
25
+
26
+ target = match[1]
27
+ fragment = match[2]
28
+ Address.new(scheme: 'doi', target: target, fragment: fragment)
29
+ end
30
+
31
+ def serialize(address)
32
+ base = address.target.to_s
33
+ return base unless address.fragment && !address.fragment.empty?
34
+
35
+ "#{base}##{address.fragment}"
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ class Address < Lutaml::Model::Serializable
6
+ # ISBN — book identifier. Recognizes an optional "ISBN" prefix
7
+ # (case insensitive) and either ISBN-10 or ISBN-13 digits. Bare
8
+ # numbers must be long enough to be ISBN-shaped so short numeric
9
+ # anchors are not claimed.
10
+ module Isbn
11
+ module_function
12
+
13
+ ISBN_PATTERN = /\A(?:ISBN\s+)?(\d[\d\-Xx]{8,16})\z/i
14
+
15
+ def scheme_name
16
+ :isbn
17
+ end
18
+
19
+ def matches?(raw)
20
+ return false if raw.nil? || raw.empty?
21
+
22
+ ISBN_PATTERN.match?(raw.to_s)
23
+ end
24
+
25
+ def parse(raw)
26
+ match = ISBN_PATTERN.match(raw.to_s)
27
+ raise Address::ParseError, "Invalid ISBN: #{raw.inspect}" unless match
28
+
29
+ target = match[1]
30
+ Address.new(scheme: 'isbn', target: target)
31
+ end
32
+
33
+ def serialize(address)
34
+ "ISBN #{address.target}"
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ class Address < Lutaml::Model::Serializable
6
+ # Inter-document path — a document identifier plus optional fragment.
7
+ # Matches document-ID-shaped barewords (uppercase + dash + digit,
8
+ # e.g. "ELF-5005-1") and relative/absolute file paths
9
+ # ("images/foo.png", "../shared/common.adoc"). Locality is carried
10
+ # in fragment.
11
+ #
12
+ # "ELF-5005-1" => path target "ELF-5005-1"
13
+ # "ELF-5005-1#sec-3" => path target "ELF-5005-1", fragment "sec-3"
14
+ # "images/foo.png" => path target "images/foo.png"
15
+ module Path
16
+ module_function
17
+
18
+ # Doc-ID pattern: uppercase-led bareword ("ELF-5005-1"). Used
19
+ # together with a separate digit scan — two linear passes, no
20
+ # ambiguous adjacent quantifiers (polynomial-ReDoS-safe).
21
+ DOC_ID_PATTERN = /\A[A-Z][A-Z0-9_\-]*\z/
22
+ # Loose pattern used by +parse+ when the scheme is already chosen
23
+ # (via hint). Accepts any non-empty target plus optional fragment.
24
+ LOOSE_PATTERN = /\A([^#]+)(?:#(.*))?\z/
25
+
26
+ def scheme_name
27
+ :path
28
+ end
29
+
30
+ def matches?(raw)
31
+ return false if raw.nil? || raw.empty?
32
+
33
+ value = raw.to_s
34
+ return true if value.include?('/')
35
+
36
+ # Document IDs are uppercase-led and contain at least one digit
37
+ # (what distinguishes them from anchors like "SECTION").
38
+ id_part = value.split('#', 2).first
39
+ DOC_ID_PATTERN.match?(id_part) && id_part.match?(/\d/)
40
+ end
41
+
42
+ def parse(raw)
43
+ match = LOOSE_PATTERN.match(raw.to_s)
44
+ raise Address::ParseError, "Invalid path: #{raw.inspect}" unless match
45
+
46
+ Address.new(
47
+ scheme: 'path',
48
+ target: match[1],
49
+ fragment: normalize_fragment(match[2])
50
+ )
51
+ end
52
+
53
+ def serialize(address)
54
+ base = address.target.to_s
55
+ return base unless address.fragment && !address.fragment.empty?
56
+
57
+ "#{base}##{address.fragment}"
58
+ end
59
+
60
+ def normalize_fragment(fragment)
61
+ fragment&.empty? ? nil : fragment
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ class Address < Lutaml::Model::Serializable
6
+ # Scoped path — a path within a collection's namespace. The scope
7
+ # identifies the collection (e.g. "ELF"); the target is the path
8
+ # inside it ("5005:1"); the fragment is locality within that doc.
9
+ #
10
+ # "ELF:5005:1#sec-3" => scope "ELF", target "5005:1", fragment "sec-3"
11
+ module ScopedPath
12
+ module_function
13
+
14
+ SCOPED_PATTERN = /\A([A-Z][A-Z0-9_\-]*):([\d:]+)(?:#(.*))?\z/
15
+
16
+ def scheme_name
17
+ :scoped_path
18
+ end
19
+
20
+ def matches?(raw)
21
+ return false if raw.nil? || raw.empty?
22
+
23
+ SCOPED_PATTERN.match?(raw.to_s)
24
+ end
25
+
26
+ def parse(raw)
27
+ match = SCOPED_PATTERN.match(raw.to_s)
28
+ raise Address::ParseError, "Invalid scoped path: #{raw.inspect}" unless match
29
+
30
+ Address.new(
31
+ scheme: 'scoped_path',
32
+ scope: match[1],
33
+ target: match[2],
34
+ fragment: match[3]
35
+ )
36
+ end
37
+
38
+ def serialize(address)
39
+ base = "#{address.scope}:#{address.target}"
40
+ return base unless address.fragment && !address.fragment.empty?
41
+
42
+ "#{base}##{address.fragment}"
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ class Address < Lutaml::Model::Serializable
6
+ # Absolute URL — anything with a recognized URL scheme prefix.
7
+ # Fragment is the optional "#..." suffix.
8
+ module Url
9
+ module_function
10
+
11
+ URL_PATTERN = %r{\A([a-z][a-z0-9+\-.]*)://([^#]+)(?:#(.*))?\z}i
12
+
13
+ def scheme_name
14
+ :url
15
+ end
16
+
17
+ def matches?(raw)
18
+ return false if raw.nil? || raw.empty?
19
+
20
+ URL_PATTERN.match?(raw.to_s)
21
+ end
22
+
23
+ def parse(raw)
24
+ match = URL_PATTERN.match(raw.to_s)
25
+ raise Address::ParseError, "Invalid URL: #{raw.inspect}" unless match
26
+
27
+ target = "#{match[1]}://#{match[2]}"
28
+ fragment = match[3]
29
+ Address.new(scheme: 'url', target: target, fragment: fragment)
30
+ end
31
+
32
+ def serialize(address)
33
+ base = address.target.to_s
34
+ return base unless address.fragment && !address.fragment.empty?
35
+
36
+ "#{base}##{address.fragment}"
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'lutaml/model'
4
+
5
+ module Coradoc
6
+ module Reference
7
+ # Scheme-aware locator targeting a Content node.
8
+ #
9
+ # An Address is one of six built-in schemes — +:anchor+, +:path+,
10
+ # +:scoped_path+, +:url+, +:doi+, +:isbn+ — selected by heuristic on
11
+ # parse or by an explicit +hint:+. External gems register additional
12
+ # schemes via +Address.register_scheme+ (OCP).
13
+ #
14
+ # Address.parse("foo") # => anchor "foo"
15
+ # Address.parse("ELF-5005-1#sec-3") # => path "ELF-5005-1", fragment "sec-3"
16
+ # Address.parse("ELF:5005:1#sec-3") # => scoped_path scope "ELF"
17
+ # Address.parse("https://x.y/z") # => url
18
+ # Address.parse("10.1234/abc") # => doi
19
+ # Address.parse("ISBN 978-1-2-3") # => isbn
20
+ #
21
+ # Addresses are value types: two addresses are equal iff every
22
+ # attribute matches. Round-trip via +to_s+.
23
+ class Address < Lutaml::Model::Serializable
24
+ class ParseError < Coradoc::Reference::Error; end
25
+ class UnknownSchemeError < Coradoc::Reference::Error; end
26
+
27
+ attribute :scheme, :string
28
+ attribute :target, :string
29
+ attribute :fragment, :string
30
+ attribute :scope, :string
31
+
32
+ autoload :Url, "#{__dir__}/address/url"
33
+ autoload :Doi, "#{__dir__}/address/doi"
34
+ autoload :Isbn, "#{__dir__}/address/isbn"
35
+ autoload :ScopedPath, "#{__dir__}/address/scoped_path"
36
+ autoload :Path, "#{__dir__}/address/path"
37
+ autoload :Anchor, "#{__dir__}/address/anchor"
38
+
39
+ BUILTIN_SCHEME_ORDER = %i[url doi isbn scoped_path path anchor].freeze
40
+
41
+ class << self
42
+ def parse(raw, hint: nil)
43
+ Scheme.ensure_builtins_registered!
44
+ mod = hint ? scheme_for_hint!(hint) : Scheme.match(raw)
45
+ unless mod
46
+ raise ParseError,
47
+ "Cannot determine address scheme for #{raw.inspect}"
48
+ end
49
+ mod.parse(raw)
50
+ end
51
+
52
+ def register_scheme(mod)
53
+ Scheme.register(mod)
54
+ end
55
+
56
+ def scheme_names
57
+ Scheme.ensure_builtins_registered!
58
+ Scheme.names
59
+ end
60
+
61
+ private
62
+
63
+ def scheme_for_hint!(hint)
64
+ Scheme.for(hint) ||
65
+ raise(UnknownSchemeError,
66
+ "Unknown address scheme #{hint.inspect}. " \
67
+ "Registered: #{Scheme.names.inspect}")
68
+ end
69
+ end
70
+
71
+ # Value equality (==/eql?/hash) comes from Lutaml::Model — all
72
+ # attributes compared, class-aware, safe as Hash keys.
73
+
74
+ def to_s
75
+ Scheme.ensure_builtins_registered!
76
+ mod = Scheme.for(scheme)
77
+ unless mod
78
+ raise UnknownSchemeError,
79
+ "No registered scheme for #{scheme.inspect}. " \
80
+ "Registered: #{Scheme.names.inspect}"
81
+ end
82
+ mod.serialize(self)
83
+ end
84
+
85
+ # Registry of scheme modules. Each module provides:
86
+ # scheme_name -> Symbol
87
+ # matches?(raw) -> Boolean
88
+ # parse(raw) -> Address
89
+ # serialize(addr) -> String
90
+ #
91
+ # +register+ always forces builtin registration first, so an
92
+ # external scheme registered before first use is never clobbered
93
+ # by the lazy builtin pass. The catch-all anchor scheme is always
94
+ # tried LAST on match, so external schemes are never shadowed by
95
+ # it no matter when they register.
96
+ module Scheme
97
+ @registered = []
98
+ @builtins_registered = false
99
+ @registering_builtins = false
100
+
101
+ CATCH_ALL_SCHEME = :anchor
102
+ private_constant :CATCH_ALL_SCHEME
103
+
104
+ MUTEX = Mutex.new
105
+ private_constant :MUTEX
106
+
107
+ class << self
108
+ def register(mod)
109
+ ensure_builtins_registered! unless @registering_builtins
110
+ @registered.delete_if { |m| m.scheme_name == mod.scheme_name }
111
+ @registered << mod
112
+ end
113
+
114
+ def names
115
+ @registered.map(&:scheme_name)
116
+ end
117
+
118
+ def for(name)
119
+ @registered.find { |m| m.scheme_name == name.to_sym }
120
+ end
121
+
122
+ def match(raw)
123
+ catch_all, specific =
124
+ @registered.partition { |m| m.scheme_name == CATCH_ALL_SCHEME }
125
+ (specific + catch_all).find { |m| m.matches?(raw) }
126
+ end
127
+
128
+ def ensure_builtins_registered!
129
+ MUTEX.synchronize do
130
+ return if @builtins_registered
131
+
132
+ @registering_builtins = true
133
+ begin
134
+ BUILTIN_SCHEME_ORDER.each do |name|
135
+ const_name = name.to_s.split('_').map(&:capitalize).join
136
+ register(Address.const_get(const_name))
137
+ end
138
+ @builtins_registered = true
139
+ ensure
140
+ @registering_builtins = false
141
+ end
142
+ end
143
+ end
144
+
145
+ def reset!
146
+ @registered.clear
147
+ @builtins_registered = false
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ module Catalog
6
+ # Composes multiple catalogs. Lookup asks EVERY child and merges
7
+ # the hits: exactly one hit returns the Content; several hits
8
+ # return an Array (which the Resolver surfaces per the
9
+ # +ambiguous:+ policy). Precedence between catalogs is a
10
+ # resolver/policy concern, not a lookup concern:
11
+ #
12
+ # Composite.new(
13
+ # Local.from_doc(doc),
14
+ # Collection.from_manifest(...),
15
+ # Remote.new(client: http_client)
16
+ # )
17
+ class Composite
18
+ include Catalog::Protocol
19
+
20
+ attr_reader :children
21
+
22
+ def initialize(*children)
23
+ @children = children.flatten
24
+ end
25
+
26
+ def lookup(address)
27
+ results = []
28
+ children.each do |catalog|
29
+ result = catalog.lookup(address)
30
+ next if result.nil?
31
+
32
+ results.concat(Array(result))
33
+ end
34
+ return nil if results.empty?
35
+ return results.first if results.size == 1
36
+
37
+ results
38
+ end
39
+
40
+ def ambiguous?(address)
41
+ result = lookup(address)
42
+ result.is_a?(Array) && result.size > 1
43
+ end
44
+
45
+ def each_pair(&block)
46
+ return to_enum(:each_pair) unless block_given?
47
+
48
+ children.each do |catalog|
49
+ catalog.each_pair(&block)
50
+ end
51
+ end
52
+
53
+ def recognizes_scheme?(scheme)
54
+ children.any? { |catalog| catalog.recognizes_scheme?(scheme) }
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ module Catalog
6
+ # Indexes one CoreModel document. Walks the tree once at
7
+ # construction; indexes every node carrying an +id+ by its anchor
8
+ # Address, plus the document itself by an optional path Address.
9
+ #
10
+ # catalog = Catalog::Local.from_doc(doc, path: "ELF-5005-1")
11
+ # catalog.lookup(Address.parse("ELF-5005-1")) # => doc
12
+ # catalog.lookup(Address.parse("sec-3")) # => section
13
+ class Local
14
+ include Catalog::Protocol
15
+
16
+ attr_reader :document, :document_path
17
+
18
+ def initialize(document:, document_path: nil, index: nil)
19
+ @document = document
20
+ @document_path = document_path
21
+ @index = index || MemoryIndex.new
22
+ populate! unless index
23
+ end
24
+
25
+ class << self
26
+ def from_doc(document, path: nil)
27
+ new(document: document, document_path: path)
28
+ end
29
+ end
30
+
31
+ def lookup(address)
32
+ @index.lookup(address)
33
+ end
34
+
35
+ def ambiguous?(address)
36
+ @index.ambiguous?(address)
37
+ end
38
+
39
+ def each_pair(&)
40
+ @index.each_pair(&)
41
+ end
42
+
43
+ def recognizes_scheme?(scheme)
44
+ return true if scheme.to_sym == :anchor
45
+ return true if scheme.to_sym == :path && document_path
46
+ return true if scheme.to_sym == :scoped_path && document_path&.include?(':')
47
+
48
+ @index.recognizes_scheme?(scheme)
49
+ end
50
+
51
+ private
52
+
53
+ def populate!
54
+ index_document_root!
55
+ walk(document)
56
+ end
57
+
58
+ def index_document_root!
59
+ return unless document_path
60
+
61
+ address = Coradoc::Reference::Address.parse(document_path, hint: path_hint)
62
+ @index.add(address, document)
63
+ end
64
+
65
+ def path_hint
66
+ document_path.include?(':') ? :scoped_path : :path
67
+ end
68
+
69
+ def walk(node)
70
+ return unless node.is_a?(Coradoc::CoreModel::Base)
71
+
72
+ index_node!(node)
73
+ walk_children(node)
74
+ end
75
+
76
+ def index_node!(node)
77
+ return unless node.id
78
+
79
+ address = Coradoc::Reference::Address.new(
80
+ scheme: 'anchor',
81
+ target: node.id
82
+ )
83
+ @index.add(address, node)
84
+ end
85
+
86
+ def walk_children(node)
87
+ return unless node.is_a?(Coradoc::CoreModel::HasChildren)
88
+
89
+ children = node.children
90
+ return if children.nil? || children.empty?
91
+
92
+ children.each { |child| walk(child) }
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ module Catalog
6
+ # Shared in-memory index. Built-in catalogs compose this rather
7
+ # than reimplementing the storage layer (DRY). The index is
8
+ # immutable from the outside — entries are added by the catalog
9
+ # during construction, then read-only.
10
+ class MemoryIndex
11
+ include Enumerable
12
+
13
+ def initialize
14
+ @by_address = {}
15
+ @schemes = Set.new
16
+ end
17
+
18
+ def add(address, content)
19
+ raise ArgumentError, 'address required' unless address.is_a?(Coradoc::Reference::Address)
20
+ raise ArgumentError, 'content required' unless content
21
+
22
+ @by_address[address] = Array(@by_address[address]) << content
23
+ @schemes << address.scheme.to_sym
24
+ self
25
+ end
26
+
27
+ def lookup(address)
28
+ entries = @by_address[address]
29
+ return nil unless entries
30
+ return entries.first if entries.size == 1
31
+
32
+ # Copy: callers must not be able to corrupt the index.
33
+ entries.dup
34
+ end
35
+
36
+ def ambiguous?(address)
37
+ entries = @by_address[address]
38
+ entries && entries.size > 1
39
+ end
40
+
41
+ def each_pair
42
+ return to_enum(:each_pair) unless block_given?
43
+
44
+ @by_address.each do |address, contents|
45
+ contents.each { |c| yield address, c }
46
+ end
47
+ end
48
+
49
+ def recognizes_scheme?(scheme)
50
+ @schemes.include?(scheme.to_sym)
51
+ end
52
+
53
+ def size
54
+ @by_address.size
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end