ace-support-nav 0.25.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.
- checksums.yaml +7 -0
- data/.ace-defaults/nav/config.yml +33 -0
- data/.ace-defaults/nav/protocols/guide-sources/ace-support-nav.yml +7 -0
- data/.ace-defaults/nav/protocols/guide.yml +69 -0
- data/.ace-defaults/nav/protocols/prompt.yml +39 -0
- data/.ace-defaults/nav/protocols/skill-sources/ace-support-nav.yml +19 -0
- data/.ace-defaults/nav/protocols/skill.yml +22 -0
- data/.ace-defaults/nav/protocols/tmpl-sources/ace-support-nav.yml +7 -0
- data/.ace-defaults/nav/protocols/tmpl.yml +55 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-support-nav.yml +7 -0
- data/.ace-defaults/nav/protocols/wfi.yml +61 -0
- data/CHANGELOG.md +231 -0
- data/LICENSE +21 -0
- data/README.md +48 -0
- data/Rakefile +12 -0
- data/docs/demo/ace-support-nav-getting-started.gif +0 -0
- data/docs/demo/ace-support-nav-getting-started.tape.yml +28 -0
- data/exe/ace-nav +49 -0
- data/handbook/workflow-instructions/test.wfi.md +14 -0
- data/lib/ace/support/nav/atoms/extension_inferrer.rb +134 -0
- data/lib/ace/support/nav/atoms/gem_resolver.rb +59 -0
- data/lib/ace/support/nav/atoms/path_normalizer.rb +52 -0
- data/lib/ace/support/nav/atoms/uri_parser.rb +62 -0
- data/lib/ace/support/nav/cli/commands/create.rb +114 -0
- data/lib/ace/support/nav/cli/commands/list.rb +122 -0
- data/lib/ace/support/nav/cli/commands/resolve.rb +187 -0
- data/lib/ace/support/nav/cli/commands/sources.rb +112 -0
- data/lib/ace/support/nav/cli.rb +66 -0
- data/lib/ace/support/nav/models/handbook_source.rb +73 -0
- data/lib/ace/support/nav/models/protocol_source.rb +104 -0
- data/lib/ace/support/nav/models/resource.rb +46 -0
- data/lib/ace/support/nav/models/resource_uri.rb +78 -0
- data/lib/ace/support/nav/molecules/config_loader.rb +275 -0
- data/lib/ace/support/nav/molecules/handbook_scanner.rb +204 -0
- data/lib/ace/support/nav/molecules/protocol_scanner.rb +434 -0
- data/lib/ace/support/nav/molecules/resource_resolver.rb +134 -0
- data/lib/ace/support/nav/molecules/source_registry.rb +133 -0
- data/lib/ace/support/nav/organisms/command_delegator.rb +122 -0
- data/lib/ace/support/nav/organisms/navigation_engine.rb +180 -0
- data/lib/ace/support/nav/version.rb +9 -0
- data/lib/ace/support/nav.rb +104 -0
- metadata +228 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../atoms/gem_resolver"
|
|
4
|
+
require_relative "../atoms/path_normalizer"
|
|
5
|
+
require_relative "../atoms/extension_inferrer"
|
|
6
|
+
require_relative "../models/handbook_source"
|
|
7
|
+
require_relative "config_loader"
|
|
8
|
+
|
|
9
|
+
module Ace
|
|
10
|
+
module Support
|
|
11
|
+
module Nav
|
|
12
|
+
module Molecules
|
|
13
|
+
# Scans for protocol resources using registered sources
|
|
14
|
+
class ProtocolScanner
|
|
15
|
+
attr_reader :config_loader
|
|
16
|
+
|
|
17
|
+
def initialize(gem_resolver: nil, path_normalizer: nil, config_loader: nil, extension_inferrer: nil)
|
|
18
|
+
@gem_resolver = gem_resolver || Atoms::GemResolver.new
|
|
19
|
+
@path_normalizer = path_normalizer || Atoms::PathNormalizer.new
|
|
20
|
+
@config_loader = config_loader || ConfigLoader.new
|
|
21
|
+
# extension_inferrer param kept for backwards compatibility but ignored
|
|
22
|
+
# ExtensionInferrer now uses class methods
|
|
23
|
+
@extension_inference_enabled = nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Get all sources for a protocol
|
|
27
|
+
def sources_for_protocol(protocol)
|
|
28
|
+
@config_loader.sources_for_protocol(protocol)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Find resources in all sources for a protocol
|
|
32
|
+
def find_resources(protocol, pattern = "*")
|
|
33
|
+
sources = sources_for_protocol(protocol)
|
|
34
|
+
protocol_config = @config_loader.load_protocol_config(protocol)
|
|
35
|
+
|
|
36
|
+
resources = []
|
|
37
|
+
|
|
38
|
+
sources.each do |source|
|
|
39
|
+
next unless source.exists?
|
|
40
|
+
|
|
41
|
+
resources.concat(find_resources_in_source_internal(source, protocol_config, pattern))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
resources
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Find resources in a specific source (internal implementation)
|
|
48
|
+
def find_resources_in_source_internal(source, protocol_config, pattern = "*")
|
|
49
|
+
# Try exact match first
|
|
50
|
+
resources = find_resources_with_extensions(source, protocol_config, pattern)
|
|
51
|
+
|
|
52
|
+
# If no results and extension inference is enabled, try with inferred extensions
|
|
53
|
+
if resources.empty? && extension_inference_enabled? && !pattern.include?("/") && pattern != "*"
|
|
54
|
+
resources = find_resources_with_inference(source, protocol_config, pattern)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
resources
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Find resources using pattern and extensions (original logic)
|
|
61
|
+
def find_resources_with_extensions(source, protocol_config, pattern = "*")
|
|
62
|
+
# Handle both ProtocolSource and HandbookSource objects
|
|
63
|
+
if source.respond_to?(:full_path)
|
|
64
|
+
return [] unless source.exists?
|
|
65
|
+
search_path = source.full_path
|
|
66
|
+
else
|
|
67
|
+
# Legacy HandbookSource
|
|
68
|
+
return [] unless source.exists?
|
|
69
|
+
search_path = source.handbook_path
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Get extensions from protocol config
|
|
73
|
+
extensions = protocol_config["extensions"] || []
|
|
74
|
+
|
|
75
|
+
resources = []
|
|
76
|
+
|
|
77
|
+
# Check if pattern contains directory structure
|
|
78
|
+
if pattern.include?("/")
|
|
79
|
+
# Handle subdirectory patterns
|
|
80
|
+
if pattern.end_with?("/")
|
|
81
|
+
# Pattern like "base/" has two interpretations:
|
|
82
|
+
# 1. Files in a subdirectory named "base"
|
|
83
|
+
# 2. Files that start with "base" prefix
|
|
84
|
+
prefix = pattern.chomp("/")
|
|
85
|
+
|
|
86
|
+
# First, try as a subdirectory
|
|
87
|
+
subdir_path = File.join(search_path, prefix)
|
|
88
|
+
has_subdir = Dir.exist?(subdir_path)
|
|
89
|
+
|
|
90
|
+
if has_subdir
|
|
91
|
+
# Subdirectory exists, list files in it
|
|
92
|
+
found_subdir_paths = Set.new # Track paths to avoid duplicates
|
|
93
|
+
|
|
94
|
+
if extensions.empty?
|
|
95
|
+
# Match any file in the subdirectory
|
|
96
|
+
glob_pattern = File.join(subdir_path, "*")
|
|
97
|
+
glob_pattern_nested = File.join(subdir_path, "**", "*")
|
|
98
|
+
|
|
99
|
+
[glob_pattern, glob_pattern_nested].each do |gp|
|
|
100
|
+
Dir.glob(gp).each do |file_path|
|
|
101
|
+
next unless File.file?(file_path)
|
|
102
|
+
next if found_subdir_paths.include?(file_path) # Skip duplicates
|
|
103
|
+
found_subdir_paths.add(file_path)
|
|
104
|
+
resources << create_resource_info(file_path, search_path, source, protocol_config["protocol"])
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
else
|
|
108
|
+
# Match files with specified extensions in the subdirectory
|
|
109
|
+
extensions.each do |ext|
|
|
110
|
+
glob_pattern = File.join(subdir_path, "*#{ext}")
|
|
111
|
+
glob_pattern_nested = File.join(subdir_path, "**", "*#{ext}")
|
|
112
|
+
|
|
113
|
+
[glob_pattern, glob_pattern_nested].each do |gp|
|
|
114
|
+
Dir.glob(gp).each do |file_path|
|
|
115
|
+
next unless File.file?(file_path)
|
|
116
|
+
next if found_subdir_paths.include?(file_path) # Skip duplicates
|
|
117
|
+
found_subdir_paths.add(file_path)
|
|
118
|
+
resources << create_resource_info(file_path, search_path, source, protocol_config["protocol"])
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Also try as a prefix pattern (files starting with prefix)
|
|
126
|
+
found_paths = Set.new # Track paths to avoid duplicates
|
|
127
|
+
|
|
128
|
+
if extensions.empty?
|
|
129
|
+
# Match files starting with prefix
|
|
130
|
+
glob_patterns = [
|
|
131
|
+
File.join(search_path, "#{prefix}*"),
|
|
132
|
+
File.join(search_path, "**", "#{prefix}*")
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
glob_patterns.each do |gp|
|
|
136
|
+
Dir.glob(gp).each do |file_path|
|
|
137
|
+
next unless File.file?(file_path)
|
|
138
|
+
next if found_paths.include?(file_path) # Skip duplicates
|
|
139
|
+
found_paths.add(file_path)
|
|
140
|
+
resources << create_resource_info(file_path, search_path, source, protocol_config["protocol"])
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
else
|
|
144
|
+
# Match files with specified extensions starting with prefix
|
|
145
|
+
extensions.each do |ext|
|
|
146
|
+
glob_patterns = [
|
|
147
|
+
File.join(search_path, "#{prefix}*#{ext}"),
|
|
148
|
+
File.join(search_path, "**", "#{prefix}*#{ext}")
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
glob_patterns.each do |gp|
|
|
152
|
+
Dir.glob(gp).each do |file_path|
|
|
153
|
+
next unless File.file?(file_path)
|
|
154
|
+
next if found_paths.include?(file_path) # Skip duplicates
|
|
155
|
+
found_paths.add(file_path)
|
|
156
|
+
resources << create_resource_info(file_path, search_path, source, protocol_config["protocol"])
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
elsif extensions.empty?
|
|
162
|
+
# Pattern like "base/*" or "base/something" - use as-is but handle properly
|
|
163
|
+
glob_pattern = File.join(search_path, pattern)
|
|
164
|
+
glob_pattern += "*" unless pattern.end_with?("*") || pattern.include?("*")
|
|
165
|
+
|
|
166
|
+
Dir.glob(glob_pattern).each do |file_path|
|
|
167
|
+
next unless File.file?(file_path)
|
|
168
|
+
resources << create_resource_info(file_path, search_path, source, protocol_config["protocol"])
|
|
169
|
+
end
|
|
170
|
+
else
|
|
171
|
+
extensions.each do |ext|
|
|
172
|
+
glob_pattern = if pattern.end_with?(ext)
|
|
173
|
+
File.join(search_path, pattern)
|
|
174
|
+
else
|
|
175
|
+
File.join(search_path, "#{pattern}#{ext}")
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
Dir.glob(glob_pattern).each do |file_path|
|
|
179
|
+
next unless File.file?(file_path)
|
|
180
|
+
resources << create_resource_info(file_path, search_path, source, protocol_config["protocol"])
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
elsif extensions.empty?
|
|
185
|
+
# Original behavior for patterns without directory structure
|
|
186
|
+
glob_pattern = File.join(search_path, "**", pattern)
|
|
187
|
+
glob_pattern += "*" unless pattern.end_with?("*")
|
|
188
|
+
|
|
189
|
+
Dir.glob(glob_pattern).each do |file_path|
|
|
190
|
+
next unless File.file?(file_path)
|
|
191
|
+
|
|
192
|
+
resources << create_resource_info(file_path, search_path, source, protocol_config["protocol"])
|
|
193
|
+
end
|
|
194
|
+
# If no extensions specified, match any file
|
|
195
|
+
else
|
|
196
|
+
# Match files with specified extensions
|
|
197
|
+
extensions.each do |ext|
|
|
198
|
+
# Check if pattern already ends with this extension
|
|
199
|
+
glob_pattern = if pattern.end_with?(ext)
|
|
200
|
+
# Pattern already has extension, search as-is
|
|
201
|
+
File.join(search_path, "**", pattern)
|
|
202
|
+
else
|
|
203
|
+
# Append extension to pattern
|
|
204
|
+
File.join(search_path, "**", "#{pattern}#{ext}")
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
Dir.glob(glob_pattern).each do |file_path|
|
|
208
|
+
next unless File.file?(file_path)
|
|
209
|
+
|
|
210
|
+
resources << create_resource_info(file_path, search_path, source, protocol_config["protocol"])
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
resources
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Find resources using extension inference when exact match fails
|
|
219
|
+
def find_resources_with_inference(source, protocol_config, pattern)
|
|
220
|
+
# Handle both ProtocolSource and HandbookSource objects
|
|
221
|
+
if source.respond_to?(:full_path)
|
|
222
|
+
return [] unless source.exists?
|
|
223
|
+
search_path = source.full_path
|
|
224
|
+
else
|
|
225
|
+
return [] unless source.exists?
|
|
226
|
+
search_path = source.handbook_path
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Get configuration for extension inference
|
|
230
|
+
settings = @config_loader.load_settings
|
|
231
|
+
inference_config = settings["extension_inference"] || {}
|
|
232
|
+
enabled = inference_config["enabled"] != false # Default true
|
|
233
|
+
fallback_order = inference_config["fallback_order"]
|
|
234
|
+
|
|
235
|
+
# Get protocol extensions
|
|
236
|
+
protocol_extensions = protocol_config["extensions"] || []
|
|
237
|
+
inferred_extensions = protocol_config["inferred_extensions"] || protocol_extensions
|
|
238
|
+
|
|
239
|
+
# Generate candidate patterns using extension inferrer
|
|
240
|
+
candidates = Atoms::ExtensionInferrer.infer_extensions(
|
|
241
|
+
pattern,
|
|
242
|
+
protocol_extensions: inferred_extensions,
|
|
243
|
+
enabled: enabled,
|
|
244
|
+
fallback_order: fallback_order
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
resources = []
|
|
248
|
+
found_paths = Set.new # Track paths to avoid duplicates
|
|
249
|
+
|
|
250
|
+
# Try each candidate pattern in order
|
|
251
|
+
candidates.each do |candidate|
|
|
252
|
+
# For inference, we need to allow additional extensions after the inferred one
|
|
253
|
+
# e.g., "mydoc.cst" should match "mydoc.cst.md"
|
|
254
|
+
# Use brace expansion for tighter matching: exact match or with extension
|
|
255
|
+
glob_pattern = File.join(search_path, "**", candidate + "{,.*}")
|
|
256
|
+
|
|
257
|
+
Dir.glob(glob_pattern).each do |file_path|
|
|
258
|
+
next unless File.file?(file_path)
|
|
259
|
+
next if found_paths.include?(file_path)
|
|
260
|
+
|
|
261
|
+
# Only match if basename equals candidate or has candidate as prefix with dot separator
|
|
262
|
+
# This prevents "multi-ext.g" from matching "multi-ext.guide.md"
|
|
263
|
+
basename = File.basename(file_path)
|
|
264
|
+
basename_candidate = File.basename(candidate)
|
|
265
|
+
next unless basename == basename_candidate ||
|
|
266
|
+
basename.start_with?(basename_candidate + ".")
|
|
267
|
+
|
|
268
|
+
found_paths.add(file_path)
|
|
269
|
+
resources << create_resource_info(file_path, search_path, source, protocol_config["protocol"])
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Stop at first match (DWIM: return first successful inference)
|
|
273
|
+
break if resources.any?
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
resources
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Check if extension inference is enabled in settings (cached)
|
|
280
|
+
def extension_inference_enabled?
|
|
281
|
+
return @extension_inference_enabled unless @extension_inference_enabled.nil?
|
|
282
|
+
|
|
283
|
+
settings = @config_loader.load_settings
|
|
284
|
+
inference_config = settings["extension_inference"] || {}
|
|
285
|
+
@extension_inference_enabled = inference_config["enabled"] != false # Default true
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Reset extension inference cache (for testing)
|
|
289
|
+
def reset_extension_inference_cache!
|
|
290
|
+
@extension_inference_enabled = nil
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Legacy wrapper method for HandbookScanner compatibility
|
|
294
|
+
def find_resources_in_source(source, protocol, pattern = "*")
|
|
295
|
+
# If second param is a string (protocol name), load its config
|
|
296
|
+
if protocol.is_a?(String)
|
|
297
|
+
protocol_config = @config_loader.load_protocol_config(protocol)
|
|
298
|
+
find_resources_in_source_internal(source, protocol_config, pattern)
|
|
299
|
+
else
|
|
300
|
+
# Already a protocol config
|
|
301
|
+
find_resources_in_source_internal(source, protocol, pattern)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Legacy method for compatibility - get all sources across all protocols
|
|
306
|
+
def scan_all_sources
|
|
307
|
+
sources = []
|
|
308
|
+
protocols = @config_loader.valid_protocols
|
|
309
|
+
|
|
310
|
+
protocols.each do |protocol|
|
|
311
|
+
protocol_sources = sources_for_protocol(protocol)
|
|
312
|
+
|
|
313
|
+
# Convert to legacy HandbookSource format for compatibility
|
|
314
|
+
protocol_sources.each do |source|
|
|
315
|
+
# The path already points to the handbook directory
|
|
316
|
+
# HandbookSource will append /handbook if needed
|
|
317
|
+
base_path = source.full_path
|
|
318
|
+
|
|
319
|
+
# Remove /handbook from the path if present since HandbookSource adds it
|
|
320
|
+
if base_path.end_with?("/handbook")
|
|
321
|
+
base_path = File.dirname(base_path)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
sources << Models::HandbookSource.new(
|
|
325
|
+
name: source.name,
|
|
326
|
+
path: base_path,
|
|
327
|
+
alias_name: "@#{source.name}",
|
|
328
|
+
type: source.type.to_sym,
|
|
329
|
+
priority: source.priority
|
|
330
|
+
)
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Remove duplicates by alias_name
|
|
335
|
+
sources.uniq { |s| s.alias_name }
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# Legacy method - scan source by alias
|
|
339
|
+
def scan_source_by_alias(alias_name)
|
|
340
|
+
# Remove @ prefix if present
|
|
341
|
+
name = alias_name.start_with?("@") ? alias_name[1..] : alias_name
|
|
342
|
+
|
|
343
|
+
# Handle special aliases
|
|
344
|
+
case name
|
|
345
|
+
when "project", "local"
|
|
346
|
+
return scan_project_source
|
|
347
|
+
when "user", "global"
|
|
348
|
+
return scan_user_source
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Find in registered sources
|
|
352
|
+
protocols = @config_loader.valid_protocols
|
|
353
|
+
|
|
354
|
+
protocols.each do |protocol|
|
|
355
|
+
source = sources_for_protocol(protocol).find { |s| s.name == name }
|
|
356
|
+
if source
|
|
357
|
+
return Models::HandbookSource.new(
|
|
358
|
+
name: source.name,
|
|
359
|
+
path: File.dirname(source.full_path),
|
|
360
|
+
alias_name: "@#{source.name}",
|
|
361
|
+
type: source.type.to_sym,
|
|
362
|
+
priority: source.priority
|
|
363
|
+
)
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
nil
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
private
|
|
371
|
+
|
|
372
|
+
def create_resource_info(file_path, search_path, source, protocol)
|
|
373
|
+
# Ensure search_path ends with a separator for proper substitution
|
|
374
|
+
normalized_search_path = search_path.end_with?("/") ? search_path : "#{search_path}/"
|
|
375
|
+
|
|
376
|
+
# Calculate relative path from the search path
|
|
377
|
+
relative_path = if file_path.start_with?(normalized_search_path)
|
|
378
|
+
file_path[normalized_search_path.length..]
|
|
379
|
+
else
|
|
380
|
+
# Fallback to original logic if path doesn't start with search_path
|
|
381
|
+
file_path.sub("#{search_path}/", "")
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Remove extension for resource path
|
|
385
|
+
protocol_config = @config_loader.load_protocol_config(protocol)
|
|
386
|
+
extensions = protocol_config["extensions"] || []
|
|
387
|
+
inferred_extensions = protocol_config["inferred_extensions"] || extensions
|
|
388
|
+
|
|
389
|
+
# Combine both lists for extension stripping
|
|
390
|
+
all_extensions = extensions | inferred_extensions
|
|
391
|
+
|
|
392
|
+
resource_path = relative_path
|
|
393
|
+
all_extensions.each do |ext|
|
|
394
|
+
resource_path = resource_path.sub(ext, "") if resource_path.end_with?(ext)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
{
|
|
398
|
+
path: file_path,
|
|
399
|
+
relative_path: resource_path,
|
|
400
|
+
source: source,
|
|
401
|
+
protocol: protocol
|
|
402
|
+
}
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def scan_project_source
|
|
406
|
+
project_path = File.expand_path("./.ace-handbook")
|
|
407
|
+
return nil unless Dir.exist?(project_path)
|
|
408
|
+
|
|
409
|
+
Models::HandbookSource.new(
|
|
410
|
+
name: "project",
|
|
411
|
+
path: project_path,
|
|
412
|
+
alias_name: "@project",
|
|
413
|
+
type: :project,
|
|
414
|
+
priority: 10
|
|
415
|
+
)
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def scan_user_source
|
|
419
|
+
user_path = File.expand_path("~/.ace-handbook")
|
|
420
|
+
return nil unless Dir.exist?(user_path)
|
|
421
|
+
|
|
422
|
+
Models::HandbookSource.new(
|
|
423
|
+
name: "user",
|
|
424
|
+
path: user_path,
|
|
425
|
+
alias_name: "@user",
|
|
426
|
+
type: :user,
|
|
427
|
+
priority: 20
|
|
428
|
+
)
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../atoms/uri_parser"
|
|
4
|
+
require_relative "../models/resource"
|
|
5
|
+
require_relative "../models/resource_uri"
|
|
6
|
+
require_relative "protocol_scanner"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Support
|
|
10
|
+
module Nav
|
|
11
|
+
module Molecules
|
|
12
|
+
# Resolves resource URIs to file paths
|
|
13
|
+
class ResourceResolver
|
|
14
|
+
def initialize(handbook_scanner: nil, uri_parser: nil, protocol_scanner: nil)
|
|
15
|
+
# Support legacy handbook_scanner parameter
|
|
16
|
+
@protocol_scanner = protocol_scanner || handbook_scanner || ProtocolScanner.new
|
|
17
|
+
@uri_parser = uri_parser || Atoms::UriParser.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def resolve(uri_string)
|
|
21
|
+
# Parse the URI
|
|
22
|
+
uri = Models::ResourceUri.new(uri_string)
|
|
23
|
+
return nil unless uri.valid?
|
|
24
|
+
|
|
25
|
+
if uri.source_specific?
|
|
26
|
+
resolve_source_specific(uri)
|
|
27
|
+
else
|
|
28
|
+
resolve_cascade(uri)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def resolve_pattern(uri_string)
|
|
33
|
+
# Parse the URI
|
|
34
|
+
uri = Models::ResourceUri.new(uri_string)
|
|
35
|
+
return [] unless uri.valid?
|
|
36
|
+
|
|
37
|
+
pattern = uri.path || "*"
|
|
38
|
+
|
|
39
|
+
if uri.source_specific?
|
|
40
|
+
resolve_pattern_source_specific(uri, pattern)
|
|
41
|
+
else
|
|
42
|
+
resolve_pattern_cascade(uri, pattern)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def resolve_source_specific(uri)
|
|
49
|
+
# Get the specific source
|
|
50
|
+
source = @protocol_scanner.scan_source_by_alias(uri.source)
|
|
51
|
+
return nil unless source
|
|
52
|
+
|
|
53
|
+
# Find the resource in that source
|
|
54
|
+
resources = @protocol_scanner.find_resources_in_source(
|
|
55
|
+
source,
|
|
56
|
+
uri.protocol,
|
|
57
|
+
uri.path || "*"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return nil if resources.empty?
|
|
61
|
+
|
|
62
|
+
# Return the first match as a Resource
|
|
63
|
+
resource_info = resources.first
|
|
64
|
+
Models::Resource.new(
|
|
65
|
+
uri: uri.to_s,
|
|
66
|
+
path: resource_info[:path],
|
|
67
|
+
source: resource_info[:source],
|
|
68
|
+
protocol: uri.protocol,
|
|
69
|
+
resource_path: resource_info[:relative_path]
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def resolve_cascade(uri)
|
|
74
|
+
# Use protocol scanner to find resources directly
|
|
75
|
+
resources = @protocol_scanner.find_resources(uri.protocol, uri.path || "*")
|
|
76
|
+
|
|
77
|
+
return nil if resources.empty?
|
|
78
|
+
|
|
79
|
+
# Return the first match (already sorted by priority)
|
|
80
|
+
resource_info = resources.first
|
|
81
|
+
Models::Resource.new(
|
|
82
|
+
uri: uri.to_s,
|
|
83
|
+
path: resource_info[:path],
|
|
84
|
+
source: resource_info[:source],
|
|
85
|
+
protocol: uri.protocol,
|
|
86
|
+
resource_path: resource_info[:relative_path]
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def resolve_pattern_source_specific(uri, pattern)
|
|
91
|
+
# Get the specific source
|
|
92
|
+
source = @protocol_scanner.scan_source_by_alias(uri.source)
|
|
93
|
+
return [] unless source
|
|
94
|
+
|
|
95
|
+
# Find matching resources in that source
|
|
96
|
+
resources = @protocol_scanner.find_resources_in_source(
|
|
97
|
+
source,
|
|
98
|
+
uri.protocol,
|
|
99
|
+
pattern
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
resources.map do |resource_info|
|
|
103
|
+
Models::Resource.new(
|
|
104
|
+
uri: "#{uri.protocol}://#{uri.source}/#{resource_info[:relative_path]}",
|
|
105
|
+
path: resource_info[:path],
|
|
106
|
+
source: resource_info[:source],
|
|
107
|
+
protocol: uri.protocol,
|
|
108
|
+
resource_path: resource_info[:relative_path]
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def resolve_pattern_cascade(uri, pattern)
|
|
114
|
+
# Use protocol scanner to find all matching resources
|
|
115
|
+
resources = @protocol_scanner.find_resources(uri.protocol, pattern)
|
|
116
|
+
all_resources = []
|
|
117
|
+
|
|
118
|
+
resources.each do |resource_info|
|
|
119
|
+
all_resources << Models::Resource.new(
|
|
120
|
+
uri: "#{uri.protocol}://#{resource_info[:relative_path]}",
|
|
121
|
+
path: resource_info[:path],
|
|
122
|
+
source: resource_info[:source],
|
|
123
|
+
protocol: uri.protocol,
|
|
124
|
+
resource_path: resource_info[:relative_path]
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
all_resources
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "ace/support/fs"
|
|
6
|
+
require_relative "../models/protocol_source"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Support
|
|
10
|
+
module Nav
|
|
11
|
+
module Molecules
|
|
12
|
+
# Discovers and manages protocol source registrations
|
|
13
|
+
class SourceRegistry
|
|
14
|
+
attr_reader :start_path
|
|
15
|
+
|
|
16
|
+
def initialize(start_path: nil)
|
|
17
|
+
@start_path = start_path
|
|
18
|
+
@sources_cache = {}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Get all sources for a protocol
|
|
22
|
+
def sources_for_protocol(protocol)
|
|
23
|
+
@sources_cache[protocol] ||= discover_sources(protocol)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Clear the cache
|
|
27
|
+
def clear_cache
|
|
28
|
+
@sources_cache.clear
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def discover_sources(protocol)
|
|
34
|
+
sources = []
|
|
35
|
+
|
|
36
|
+
# Discover from user directory
|
|
37
|
+
sources.concat(discover_user_sources(protocol))
|
|
38
|
+
|
|
39
|
+
# Discover from project hierarchy
|
|
40
|
+
sources.concat(discover_project_sources(protocol))
|
|
41
|
+
|
|
42
|
+
# Sort by priority (lower number = higher priority)
|
|
43
|
+
sources.sort_by(&:priority)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def discover_user_sources(protocol)
|
|
47
|
+
sources = []
|
|
48
|
+
user_sources_dir = File.expand_path("~/.ace/nav/protocols/#{protocol}-sources")
|
|
49
|
+
|
|
50
|
+
return sources unless Dir.exist?(user_sources_dir)
|
|
51
|
+
|
|
52
|
+
Dir.glob(File.join(user_sources_dir, "*.yml")).each do |source_file|
|
|
53
|
+
source = load_source_file(source_file, "user")
|
|
54
|
+
sources << source if source
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
sources
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def discover_project_sources(protocol)
|
|
61
|
+
sources = []
|
|
62
|
+
|
|
63
|
+
# Use directory traverser to find all .ace directories up to project root
|
|
64
|
+
traverser = Ace::Support::Fs::Molecules::DirectoryTraverser.new(start_path: start_path || Dir.pwd)
|
|
65
|
+
config_dirs = traverser.find_config_directories
|
|
66
|
+
|
|
67
|
+
# Check each .ace directory for protocol sources
|
|
68
|
+
config_dirs.each do |config_dir|
|
|
69
|
+
sources_dir = File.join(config_dir, "nav/protocols", "#{protocol}-sources")
|
|
70
|
+
next unless Dir.exist?(sources_dir)
|
|
71
|
+
|
|
72
|
+
Dir.glob(File.join(sources_dir, "*.yml")).each do |source_file|
|
|
73
|
+
source = load_source_file(source_file, "project")
|
|
74
|
+
sources << source if source
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
sources
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def load_source_file(file_path, origin)
|
|
82
|
+
data = YAML.load_file(file_path)
|
|
83
|
+
return nil unless data.is_a?(Hash)
|
|
84
|
+
|
|
85
|
+
# Expand environment variables in path (only for non-gem types)
|
|
86
|
+
path = (data["type"] == "gem") ? nil : expand_path(data["path"]) if data["path"]
|
|
87
|
+
|
|
88
|
+
# Log warning if path is provided for gem type
|
|
89
|
+
if data["type"] == "gem" && data["path"]
|
|
90
|
+
warn "Warning: 'path' field is ignored for gem type sources (#{data["name"]})" if ENV["VERBOSE"]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
Models::ProtocolSource.new(
|
|
94
|
+
name: data["name"] || File.basename(file_path, ".yml"),
|
|
95
|
+
type: data["type"] || "directory",
|
|
96
|
+
path: path,
|
|
97
|
+
priority: data["priority"] || default_priority(origin),
|
|
98
|
+
description: data["description"],
|
|
99
|
+
origin: origin,
|
|
100
|
+
config_file: file_path,
|
|
101
|
+
config_dir: file_path, # Pass the config file path for relative resolution
|
|
102
|
+
config: data["config"] # Pass the config section
|
|
103
|
+
)
|
|
104
|
+
rescue => e
|
|
105
|
+
warn "Failed to load source file #{file_path}: #{e.message}"
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def expand_path(path)
|
|
110
|
+
# Expand environment variables
|
|
111
|
+
path = path.gsub(/\$(\w+)/) { ENV.fetch($1, "") }
|
|
112
|
+
path = path.gsub("$HOME", ENV["HOME"]) if ENV["HOME"]
|
|
113
|
+
path = path.gsub("$USER", ENV["USER"]) if ENV["USER"]
|
|
114
|
+
|
|
115
|
+
# Don't expand relative paths for gem sources
|
|
116
|
+
path
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def default_priority(origin)
|
|
120
|
+
case origin
|
|
121
|
+
when "project"
|
|
122
|
+
10 # Highest priority
|
|
123
|
+
when "user"
|
|
124
|
+
50 # Medium priority
|
|
125
|
+
else
|
|
126
|
+
100 # Lower priority for gems
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|