ace-support-fs 0.3.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/CHANGELOG.md +74 -0
- data/LICENSE +21 -0
- data/README.md +29 -0
- data/Rakefile +14 -0
- data/lib/ace/support/fs/atoms/path_expander.rb +316 -0
- data/lib/ace/support/fs/errors.rb +13 -0
- data/lib/ace/support/fs/molecules/directory_traverser.rb +117 -0
- data/lib/ace/support/fs/molecules/project_root_finder.rb +167 -0
- data/lib/ace/support/fs/version.rb +9 -0
- data/lib/ace/support/fs.rb +23 -0
- metadata +56 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2c4e0121b057bd9f4bc10f6ecfd197a0df1a6050e18f1a82a855e4364a85629c
|
|
4
|
+
data.tar.gz: 23b62d54c4026a400e90c2a72577b87c8e482219d9035f1a867713d76a511e40
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 329d52c1e0c1fb79a4466732188680570b3003cd3333a29a87c1283bc235d0d4f38c53d34e6b118aa9fe53e78e0197527f867714e5e58ccaa3a9bec0d93a8321
|
|
7
|
+
data.tar.gz: 3abd750584076f9e59e94a39651db0cafbf58efac334282f083c9fffcb4e541f37d877155512e5ceed0b88f92a929271cbe8c95d772fa2fcbbef5686960a2da0
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.3.0] - 2026-03-23
|
|
11
|
+
|
|
12
|
+
### Technical
|
|
13
|
+
- Removed phantom `handbook/**/*` glob from gemspec (no handbook directory exists).
|
|
14
|
+
|
|
15
|
+
## [0.2.3] - 2026-03-22
|
|
16
|
+
|
|
17
|
+
### Technical
|
|
18
|
+
- Updated README dependency example to use the current `~> 0.2` version constraint.
|
|
19
|
+
|
|
20
|
+
## [0.2.2] - 2026-03-22
|
|
21
|
+
|
|
22
|
+
### Technical
|
|
23
|
+
- Refreshed README structure with consistent tagline, overview, basic usage, and ACE project footer
|
|
24
|
+
|
|
25
|
+
## [0.2.1] - 2026-03-08
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
- Ignore `PROJECT_ROOT_PATH` overrides when the configured root does not contain the active `start_path`, preventing unrelated workspace roots from leaking into traversal boundaries.
|
|
29
|
+
|
|
30
|
+
### Technical
|
|
31
|
+
- Added regression coverage for environment-root fallback behavior in `ProjectRootFinder`.
|
|
32
|
+
- Stabilized directory traversal molecule tests by isolating no-project-root fixtures from ambient system-level config directories.
|
|
33
|
+
|
|
34
|
+
## [0.2.0] - 2026-01-03
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
- **BREAKING**: Minimum Ruby version raised to 3.3.0 (was 3.1.0)
|
|
38
|
+
- Standardized gemspec file patterns with deterministic Dir.glob
|
|
39
|
+
- Added MIT LICENSE file
|
|
40
|
+
|
|
41
|
+
## [0.1.0] - 2025-12-28
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- Initial release extracting filesystem utilities from ace-support-core and ace-config
|
|
46
|
+
- **PathExpander** atom with:
|
|
47
|
+
- Instance-based API (`for_file`, `for_cli`) for context-aware path resolution
|
|
48
|
+
- Protocol URI support (wfi://, guide://, tmpl://, etc.) with pluggable resolver
|
|
49
|
+
- Environment variable expansion ($VAR and ${VAR} formats)
|
|
50
|
+
- Source-relative (./) and project-relative path resolution
|
|
51
|
+
- Thread-safe protocol resolver registration with Mutex
|
|
52
|
+
- Backward-compatible class methods (expand, join, dirname, basename, etc.)
|
|
53
|
+
- Testability helper (`class_get_env` for ENV stubbing in tests)
|
|
54
|
+
- **ProjectRootFinder** molecule with:
|
|
55
|
+
- Project root detection by marker files (.git, Gemfile, package.json, etc.)
|
|
56
|
+
- Thread-safe caching with Mutex
|
|
57
|
+
- PROJECT_ROOT_PATH environment variable support
|
|
58
|
+
- Instance and class method APIs
|
|
59
|
+
- Testability helper (`env_project_root` for ENV stubbing)
|
|
60
|
+
- **DirectoryTraverser** molecule with:
|
|
61
|
+
- Config directory discovery from current to project root
|
|
62
|
+
- Cascade priority building for nearest-wins resolution
|
|
63
|
+
- Home directory inclusion with lower priority
|
|
64
|
+
- Configurable config directory name (default: `.ace`)
|
|
65
|
+
- **PathError** exception class for protocol resolution failures
|
|
66
|
+
|
|
67
|
+
### Design Decisions
|
|
68
|
+
|
|
69
|
+
- Exception-based error handling (raise PathError) following ace-config pattern
|
|
70
|
+
- Thread-safety with instance variables + Mutex (not class variables)
|
|
71
|
+
- DEFAULT_MARKERS constant for project root markers
|
|
72
|
+
- `config_dir` parameter name (not `config_dir_name`)
|
|
73
|
+
- `class_get_env` for testability without subprocess overhead
|
|
74
|
+
- No Windows support (not tested or supported)
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Michal Czyz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<h1> ACE - Support FS </h1>
|
|
3
|
+
|
|
4
|
+
File system primitives for ACE path resolution and project root discovery.
|
|
5
|
+
|
|
6
|
+
<img src="https://raw.githubusercontent.com/cs3b/ace/main/docs/brand/AgenticCodingEnvironment.Logo.XS.jpg" alt="ACE Logo" width="480">
|
|
7
|
+
<br><br>
|
|
8
|
+
|
|
9
|
+
<a href="https://rubygems.org/gems/ace-support-fs"><img alt="Gem Version" src="https://img.shields.io/gem/v/ace-support-fs.svg" /></a>
|
|
10
|
+
<a href="https://www.ruby-lang.org"><img alt="Ruby" src="https://img.shields.io/badge/Ruby-3.2+-CC342D?logo=ruby" /></a>
|
|
11
|
+
<a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-blue.svg" /></a>
|
|
12
|
+
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
> Works with: Claude Code, Codex CLI, OpenCode, Gemini CLI, pi-agent, and more.
|
|
16
|
+
|
|
17
|
+
`ace-support-fs` provides reusable filesystem helpers for path expansion, root detection, and directory traversal. It handles the platform and context differences so packages like [ace-support-config](../ace-support-config) and [ace-search](../ace-search) can resolve paths safely from any working directory.
|
|
18
|
+
|
|
19
|
+
## Use Cases
|
|
20
|
+
|
|
21
|
+
**Resolve paths safely across subdirectories** - use consistent path expansion for tools that move across project subdirectories, including protocol-aware path handling.
|
|
22
|
+
|
|
23
|
+
**Detect workspace boundaries** - infer project root from marker files without shell-specific assumptions, so [ace-search](../ace-search) and [ace-support-config](../ace-support-config) scope correctly.
|
|
24
|
+
|
|
25
|
+
**Build config scans** - discover and rank candidate configuration directories during resolution, supporting the cascade logic in [ace-support-config](../ace-support-config).
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
Part of [ACE](https://github.com/cs3b/ace)
|
data/Rakefile
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rake/testtask"
|
|
5
|
+
|
|
6
|
+
Rake::TestTask.new(:test) do |t|
|
|
7
|
+
t.libs << "test" << "lib"
|
|
8
|
+
t.test_files = FileList["test/**/*_test.rb"]
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Add :spec alias for CI compatibility
|
|
12
|
+
task spec: :test
|
|
13
|
+
|
|
14
|
+
task default: :test
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Support
|
|
7
|
+
module Fs
|
|
8
|
+
module Atoms
|
|
9
|
+
# Path expansion and resolution with automatic context inference
|
|
10
|
+
#
|
|
11
|
+
# Supports:
|
|
12
|
+
# - Instance-based API for context-aware resolution
|
|
13
|
+
# - Protocol URIs (wfi://, guide://, tmpl://, task://, prompt://)
|
|
14
|
+
# - Source-relative paths (./, ../)
|
|
15
|
+
# - Project-relative paths (no prefix)
|
|
16
|
+
# - Environment variables ($VAR, ${VAR})
|
|
17
|
+
# - Backward compatible class methods for utilities
|
|
18
|
+
class PathExpander
|
|
19
|
+
# Protocol pattern for URI detection
|
|
20
|
+
PROTOCOL_PATTERN = %r{^[a-z][a-z0-9+.-]*://}
|
|
21
|
+
|
|
22
|
+
# Instance attributes
|
|
23
|
+
attr_reader :source_dir, :project_root
|
|
24
|
+
|
|
25
|
+
# Thread-safe protocol resolver registry
|
|
26
|
+
@protocol_resolver = nil
|
|
27
|
+
@protocol_resolver_mutex = Mutex.new
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
attr_reader :protocol_resolver_mutex
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# === Factory Methods ===
|
|
36
|
+
|
|
37
|
+
# Create expander for a source file (config, workflow, template, prompt)
|
|
38
|
+
# Automatically infers source_dir, uses provided or default project_root
|
|
39
|
+
#
|
|
40
|
+
# @param source_file [String] Path to source file
|
|
41
|
+
# @param project_root [String, nil] Optional explicit project root (recommended)
|
|
42
|
+
# @return [PathExpander] Instance with inferred context
|
|
43
|
+
def self.for_file(source_file, project_root: nil)
|
|
44
|
+
expanded_source = File.expand_path(source_file)
|
|
45
|
+
source_dir = File.dirname(expanded_source)
|
|
46
|
+
|
|
47
|
+
# Use provided project_root or fall back to current directory
|
|
48
|
+
# Note: For full project root detection, caller should use
|
|
49
|
+
# Ace::Support::Fs::Molecules::ProjectRootFinder and pass the result
|
|
50
|
+
resolved_root = project_root || Dir.pwd
|
|
51
|
+
|
|
52
|
+
new(source_dir: source_dir, project_root: resolved_root)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Create expander for CLI context (no source file)
|
|
56
|
+
# Uses current directory as source_dir
|
|
57
|
+
#
|
|
58
|
+
# @param project_root [String, nil] Optional explicit project root
|
|
59
|
+
# @return [PathExpander] Instance with CLI context
|
|
60
|
+
def self.for_cli(project_root: nil)
|
|
61
|
+
resolved_root = project_root || Dir.pwd
|
|
62
|
+
|
|
63
|
+
new(
|
|
64
|
+
source_dir: Dir.pwd,
|
|
65
|
+
project_root: resolved_root
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# === Instance Methods ===
|
|
70
|
+
|
|
71
|
+
# Initialize with explicit context
|
|
72
|
+
#
|
|
73
|
+
# @param source_dir [String] Source document directory (REQUIRED)
|
|
74
|
+
# @param project_root [String] Project root directory (REQUIRED)
|
|
75
|
+
# @raise [ArgumentError] if either parameter is nil
|
|
76
|
+
def initialize(source_dir:, project_root:)
|
|
77
|
+
if source_dir.nil? || project_root.nil?
|
|
78
|
+
raise ArgumentError, "PathExpander requires both 'source_dir' and 'project_root' " \
|
|
79
|
+
"(got source_dir: #{source_dir.inspect}, project_root: #{project_root.inspect})"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
@source_dir = source_dir
|
|
83
|
+
@project_root = project_root
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Resolve path using instance context
|
|
87
|
+
# Handles: protocols, source-relative (./), project-relative, env vars, absolute
|
|
88
|
+
#
|
|
89
|
+
# @param path [String] Path to resolve
|
|
90
|
+
# @return [String] Resolved absolute path
|
|
91
|
+
# @raise [PathError] When protocol cannot be resolved
|
|
92
|
+
def resolve(path)
|
|
93
|
+
return nil if path.nil? || path.empty?
|
|
94
|
+
|
|
95
|
+
path_str = path.to_s
|
|
96
|
+
|
|
97
|
+
# Check for protocol URIs first
|
|
98
|
+
if self.class.protocol?(path_str)
|
|
99
|
+
return resolve_protocol(path_str)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Expand environment variables
|
|
103
|
+
expanded = expand_env_vars(path_str)
|
|
104
|
+
|
|
105
|
+
# Handle absolute paths
|
|
106
|
+
return File.expand_path(expanded) if Pathname.new(expanded).absolute?
|
|
107
|
+
|
|
108
|
+
# Handle source-relative paths (./ or ../)
|
|
109
|
+
if expanded.start_with?("./", "../")
|
|
110
|
+
return File.expand_path(expanded, @source_dir)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Default: project-relative paths
|
|
114
|
+
File.expand_path(expanded, @project_root)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# === Class Methods (Utilities and Backward Compatibility) ===
|
|
118
|
+
|
|
119
|
+
# Check if path is a protocol URI
|
|
120
|
+
#
|
|
121
|
+
# @param path [String] Path to check
|
|
122
|
+
# @return [Boolean] true if protocol format detected
|
|
123
|
+
def self.protocol?(path)
|
|
124
|
+
return false if path.nil? || path.empty?
|
|
125
|
+
|
|
126
|
+
!!(path.to_s =~ PROTOCOL_PATTERN)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Register a protocol resolver (e.g., ace-nav)
|
|
130
|
+
# Thread-safe registration using mutex.
|
|
131
|
+
#
|
|
132
|
+
# @param resolver [Object] Resolver responding to #resolve(uri)
|
|
133
|
+
def self.register_protocol_resolver(resolver)
|
|
134
|
+
protocol_resolver_mutex.synchronize do
|
|
135
|
+
@protocol_resolver = resolver
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Get the current protocol resolver (thread-safe)
|
|
140
|
+
# @return [Object, nil] Current resolver or nil
|
|
141
|
+
def self.protocol_resolver
|
|
142
|
+
protocol_resolver_mutex.synchronize do
|
|
143
|
+
@protocol_resolver
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Reset protocol resolver (for testing)
|
|
148
|
+
# @api private
|
|
149
|
+
def self.reset_protocol_resolver!
|
|
150
|
+
protocol_resolver_mutex.synchronize do
|
|
151
|
+
@protocol_resolver = nil
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Expand path with tilde and environment variables
|
|
156
|
+
# Legacy stateless method for backward compatibility
|
|
157
|
+
#
|
|
158
|
+
# @param path [String] Path to expand
|
|
159
|
+
# @return [String] Expanded absolute path
|
|
160
|
+
def self.expand(path)
|
|
161
|
+
return nil if path.nil?
|
|
162
|
+
|
|
163
|
+
expanded = path.to_s.dup
|
|
164
|
+
|
|
165
|
+
# Expand environment variables (uses class_get_env for testability)
|
|
166
|
+
expanded.gsub!(/\$([A-Z_][A-Z0-9_]*)/i) do |match|
|
|
167
|
+
class_get_env(match[1..-1]) || match
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Expand tilde
|
|
171
|
+
File.expand_path(expanded)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Access environment variable by name (class-level)
|
|
175
|
+
# Extracted to allow test stubbing without modifying global ENV
|
|
176
|
+
#
|
|
177
|
+
# @param var_name [String] Environment variable name
|
|
178
|
+
# @return [String, nil] Environment variable value or nil if not set
|
|
179
|
+
def self.class_get_env(var_name)
|
|
180
|
+
ENV[var_name]
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Join path components safely
|
|
184
|
+
#
|
|
185
|
+
# @param parts [Array<String>] Path parts to join
|
|
186
|
+
# @return [String] Joined path
|
|
187
|
+
def self.join(*parts)
|
|
188
|
+
parts = parts.flatten.compact.map(&:to_s)
|
|
189
|
+
return "" if parts.empty?
|
|
190
|
+
|
|
191
|
+
File.join(*parts)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Get directory name from path
|
|
195
|
+
#
|
|
196
|
+
# @param path [String] File path
|
|
197
|
+
# @return [String] Directory path
|
|
198
|
+
def self.dirname(path)
|
|
199
|
+
return nil if path.nil?
|
|
200
|
+
|
|
201
|
+
File.dirname(path.to_s)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Get base name from path
|
|
205
|
+
#
|
|
206
|
+
# @param path [String] File path
|
|
207
|
+
# @return [String] Base name
|
|
208
|
+
def self.basename(path, suffix = nil)
|
|
209
|
+
return nil if path.nil?
|
|
210
|
+
|
|
211
|
+
if suffix
|
|
212
|
+
File.basename(path.to_s, suffix)
|
|
213
|
+
else
|
|
214
|
+
File.basename(path.to_s)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Check if path is absolute
|
|
219
|
+
#
|
|
220
|
+
# @param path [String] Path to check
|
|
221
|
+
# @return [Boolean] true if absolute path
|
|
222
|
+
def self.absolute?(path)
|
|
223
|
+
return false if path.nil?
|
|
224
|
+
|
|
225
|
+
path_str = path.to_s
|
|
226
|
+
Pathname.new(path_str).absolute?
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Make path relative to base
|
|
230
|
+
#
|
|
231
|
+
# @param path [String] Path to make relative
|
|
232
|
+
# @param base [String] Base path
|
|
233
|
+
# @return [String] Relative path
|
|
234
|
+
def self.relative(path, base)
|
|
235
|
+
return nil if path.nil? || base.nil?
|
|
236
|
+
|
|
237
|
+
path_obj = Pathname.new(expand(path))
|
|
238
|
+
base_obj = Pathname.new(expand(base))
|
|
239
|
+
|
|
240
|
+
path_obj.relative_path_from(base_obj).to_s
|
|
241
|
+
rescue ArgumentError
|
|
242
|
+
# Paths are on different drives or one is relative
|
|
243
|
+
path
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Normalize path (remove .., ., duplicates slashes)
|
|
247
|
+
#
|
|
248
|
+
# @param path [String] Path to normalize
|
|
249
|
+
# @return [String] Normalized path
|
|
250
|
+
def self.normalize(path)
|
|
251
|
+
return nil if path.nil?
|
|
252
|
+
|
|
253
|
+
Pathname.new(path.to_s).cleanpath.to_s
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
protected
|
|
257
|
+
|
|
258
|
+
# Access environment variable by name
|
|
259
|
+
# Extracted to allow test stubbing without modifying global ENV
|
|
260
|
+
#
|
|
261
|
+
# @param var_name [String] Environment variable name
|
|
262
|
+
# @return [String, nil] Environment variable value or nil if not set
|
|
263
|
+
def get_env(var_name)
|
|
264
|
+
ENV[var_name]
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
private
|
|
268
|
+
|
|
269
|
+
# Resolve protocol URI
|
|
270
|
+
# @raise [PathError] if no protocol resolver is registered
|
|
271
|
+
def resolve_protocol(uri)
|
|
272
|
+
resolver = self.class.protocol_resolver
|
|
273
|
+
if resolver && resolver.respond_to?(:resolve)
|
|
274
|
+
result = resolver.resolve(uri)
|
|
275
|
+
# If resolver returns a Resource with path, extract it
|
|
276
|
+
return result.path if result.respond_to?(:path)
|
|
277
|
+
# Otherwise return the result as-is
|
|
278
|
+
return result
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# No resolver registered - raise exception for consistent error handling
|
|
282
|
+
raise PathError,
|
|
283
|
+
"Protocol '#{uri}' could not be resolved. " \
|
|
284
|
+
"Register a protocol resolver with PathExpander.register_protocol_resolver(resolver)"
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Expand environment variables in path
|
|
288
|
+
#
|
|
289
|
+
# @note If an environment variable is not set, the original reference
|
|
290
|
+
# (e.g., "$VAR" or "${VAR}") is preserved in the output. This allows
|
|
291
|
+
# deferred resolution or detection of missing variables by the caller.
|
|
292
|
+
# Callers should validate paths if undefined variables are unacceptable.
|
|
293
|
+
#
|
|
294
|
+
# @param path [String] Path containing environment variable references
|
|
295
|
+
# @return [String] Path with environment variables expanded
|
|
296
|
+
def expand_env_vars(path)
|
|
297
|
+
expanded = path.dup
|
|
298
|
+
|
|
299
|
+
# Handle ${VAR} format
|
|
300
|
+
expanded.gsub!(/\$\{([A-Z_][A-Z0-9_]*)\}/i) do |match|
|
|
301
|
+
var_name = match[2..-2] # Remove ${ and }
|
|
302
|
+
get_env(var_name) || match
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Handle $VAR format
|
|
306
|
+
expanded.gsub!(/\$([A-Z_][A-Z0-9_]*)/i) do |match|
|
|
307
|
+
get_env(match[1..-1]) || match
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
expanded
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Support
|
|
5
|
+
module Fs
|
|
6
|
+
# Base error class for all ace-support-fs errors
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
|
|
9
|
+
# Raised when a path cannot be resolved
|
|
10
|
+
class PathError < Error; end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../atoms/path_expander"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Support
|
|
7
|
+
module Fs
|
|
8
|
+
module Molecules
|
|
9
|
+
# Traverses directory tree to find configuration directories
|
|
10
|
+
class DirectoryTraverser
|
|
11
|
+
attr_reader :config_dir
|
|
12
|
+
|
|
13
|
+
# Initialize traverser
|
|
14
|
+
# @param config_dir [String] Name of config directory to find (default: ".ace")
|
|
15
|
+
# @param start_path [String] Path to start traversal from
|
|
16
|
+
def initialize(config_dir: ".ace", start_path: nil)
|
|
17
|
+
@config_dir = config_dir
|
|
18
|
+
@start_path = start_path ? Atoms::PathExpander.expand(start_path) : Dir.pwd
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Traverse from current directory up to project root or filesystem root
|
|
22
|
+
# @return [Array<String>] Ordered list of directories containing config folders
|
|
23
|
+
def traverse
|
|
24
|
+
directories = []
|
|
25
|
+
current_path = @start_path
|
|
26
|
+
visited = Set.new
|
|
27
|
+
|
|
28
|
+
# Find project root
|
|
29
|
+
project_root = ProjectRootFinder.find(start_path: @start_path)
|
|
30
|
+
|
|
31
|
+
# Traverse up from current directory
|
|
32
|
+
loop do
|
|
33
|
+
# Avoid infinite loops
|
|
34
|
+
break if visited.include?(current_path)
|
|
35
|
+
visited.add(current_path)
|
|
36
|
+
|
|
37
|
+
# Check if config directory exists at this level
|
|
38
|
+
config_path = File.join(current_path, @config_dir)
|
|
39
|
+
directories << current_path if Dir.exist?(config_path)
|
|
40
|
+
|
|
41
|
+
# Get parent directory
|
|
42
|
+
parent = File.dirname(current_path)
|
|
43
|
+
|
|
44
|
+
# Stop if we've reached filesystem root
|
|
45
|
+
break if parent == current_path
|
|
46
|
+
|
|
47
|
+
# Stop if we've gone past project root (if it exists)
|
|
48
|
+
# We check after adding current_path to allow project root itself
|
|
49
|
+
if project_root && current_path == project_root
|
|
50
|
+
# Already processed project root, can stop
|
|
51
|
+
break
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
current_path = parent
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
directories
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Find all config directories in the traversal path
|
|
61
|
+
# @return [Array<String>] Full paths to config directories
|
|
62
|
+
def find_config_directories
|
|
63
|
+
traverse.map { |dir| File.join(dir, @config_dir) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Get the directory hierarchy from current to root
|
|
67
|
+
# @return [Array<String>] All directories from current to project/filesystem root
|
|
68
|
+
def directory_hierarchy
|
|
69
|
+
hierarchy = []
|
|
70
|
+
current_path = @start_path
|
|
71
|
+
|
|
72
|
+
# Find project root
|
|
73
|
+
project_root = ProjectRootFinder.find(start_path: @start_path)
|
|
74
|
+
stop_at = project_root || "/"
|
|
75
|
+
|
|
76
|
+
loop do
|
|
77
|
+
hierarchy << current_path
|
|
78
|
+
|
|
79
|
+
# Stop if we've reached our stopping point
|
|
80
|
+
break if current_path == stop_at
|
|
81
|
+
|
|
82
|
+
# Get parent directory
|
|
83
|
+
parent = File.dirname(current_path)
|
|
84
|
+
|
|
85
|
+
# Stop if we've reached filesystem root
|
|
86
|
+
break if parent == current_path
|
|
87
|
+
|
|
88
|
+
current_path = parent
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
hierarchy
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Build cascade paths with priorities
|
|
95
|
+
# @return [Hash<String, Integer>] Map of directory paths to priorities
|
|
96
|
+
def build_cascade_priorities
|
|
97
|
+
priorities = {}
|
|
98
|
+
directories = find_config_directories
|
|
99
|
+
|
|
100
|
+
# Assign priorities - closer to cwd = higher priority (lower number)
|
|
101
|
+
directories.each_with_index do |dir, index|
|
|
102
|
+
priorities[dir] = index * 10
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Add home directory with lower priority
|
|
106
|
+
home_config = File.expand_path("~/#{@config_dir}")
|
|
107
|
+
if Dir.exist?(home_config) && !priorities.key?(home_config)
|
|
108
|
+
priorities[home_config] = (directories.length + 1) * 10
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
priorities
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require_relative "../atoms/path_expander"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Support
|
|
8
|
+
module Fs
|
|
9
|
+
module Molecules
|
|
10
|
+
# Find project root directory based on markers
|
|
11
|
+
class ProjectRootFinder
|
|
12
|
+
# Common project root markers in order of preference
|
|
13
|
+
DEFAULT_MARKERS = %w[
|
|
14
|
+
.git
|
|
15
|
+
Gemfile
|
|
16
|
+
package.json
|
|
17
|
+
Cargo.toml
|
|
18
|
+
pyproject.toml
|
|
19
|
+
go.mod
|
|
20
|
+
.hg
|
|
21
|
+
.svn
|
|
22
|
+
Rakefile
|
|
23
|
+
Makefile
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
# Thread-safe cache for project root detection
|
|
27
|
+
@cache = {}
|
|
28
|
+
@cache_mutex = Mutex.new
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
attr_reader :cache_mutex
|
|
32
|
+
|
|
33
|
+
def cache
|
|
34
|
+
@cache ||= {}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Initialize finder with optional custom markers
|
|
39
|
+
# @param markers [Array<String>] Project root markers to look for
|
|
40
|
+
# @param start_path [String] Path to start searching from (default: current directory)
|
|
41
|
+
# @raise [ArgumentError] if markers is nil or empty
|
|
42
|
+
def initialize(markers: DEFAULT_MARKERS, start_path: nil)
|
|
43
|
+
if markers.nil? || markers.empty?
|
|
44
|
+
raise ArgumentError, "markers cannot be nil or empty"
|
|
45
|
+
end
|
|
46
|
+
@markers = markers
|
|
47
|
+
@start_path = start_path ? Atoms::PathExpander.expand(start_path) : Dir.pwd
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Find project root directory with caching
|
|
51
|
+
# @return [String, nil] Project root path or nil if not found
|
|
52
|
+
def find
|
|
53
|
+
# Check environment variable first
|
|
54
|
+
project_root_env = env_project_root
|
|
55
|
+
if project_root_env && !project_root_env.empty?
|
|
56
|
+
project_root = Atoms::PathExpander.expand(project_root_env)
|
|
57
|
+
if Dir.exist?(project_root) && path_within_root?(@start_path, project_root)
|
|
58
|
+
return project_root
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
cache_key = "#{@start_path}:#{@markers.join(",")}"
|
|
63
|
+
|
|
64
|
+
# Thread-safe cache access
|
|
65
|
+
self.class.cache_mutex.synchronize do
|
|
66
|
+
# Return cached result if available
|
|
67
|
+
return self.class.cache[cache_key] if self.class.cache.key?(cache_key)
|
|
68
|
+
|
|
69
|
+
# Find and cache the result
|
|
70
|
+
result = find_without_cache
|
|
71
|
+
self.class.cache[cache_key] = result
|
|
72
|
+
result
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Find project root or fall back to current directory
|
|
77
|
+
# @return [String] Project root path or current directory
|
|
78
|
+
def find_or_current
|
|
79
|
+
find || Dir.pwd
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Check if we're in a project directory
|
|
83
|
+
# @return [Boolean] true if project root is found
|
|
84
|
+
def in_project?
|
|
85
|
+
!find.nil?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Get the relative path from project root to a given path
|
|
89
|
+
# @param path [String] Path to make relative
|
|
90
|
+
# @return [String, nil] Relative path or nil if not in project
|
|
91
|
+
def relative_path(path)
|
|
92
|
+
root = find
|
|
93
|
+
return nil unless root
|
|
94
|
+
|
|
95
|
+
# Use realpath to handle symlinks and resolve paths
|
|
96
|
+
real_root = File.realpath(root)
|
|
97
|
+
real_path = File.realpath(Atoms::PathExpander.expand(path))
|
|
98
|
+
|
|
99
|
+
return nil unless real_path.start_with?(real_root)
|
|
100
|
+
|
|
101
|
+
Pathname.new(real_path).relative_path_from(Pathname.new(real_root)).to_s
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Clear the cache (thread-safe)
|
|
105
|
+
def self.clear_cache!
|
|
106
|
+
cache_mutex.synchronize do
|
|
107
|
+
cache.clear
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Class method for convenience
|
|
112
|
+
# @return [String, nil] Project root path
|
|
113
|
+
def self.find(start_path: nil, markers: DEFAULT_MARKERS)
|
|
114
|
+
new(start_path: start_path, markers: markers).find
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Class method to find or use current directory
|
|
118
|
+
# @return [String] Project root path or current directory
|
|
119
|
+
def self.find_or_current(start_path: nil, markers: DEFAULT_MARKERS)
|
|
120
|
+
new(start_path: start_path, markers: markers).find_or_current
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
protected
|
|
124
|
+
|
|
125
|
+
# Access PROJECT_ROOT_PATH environment variable
|
|
126
|
+
# Extracted to allow test stubbing without modifying global ENV
|
|
127
|
+
def env_project_root
|
|
128
|
+
ENV["PROJECT_ROOT_PATH"]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
# Find project root without using cache
|
|
134
|
+
# @return [String, nil] Project root path or nil if not found
|
|
135
|
+
def find_without_cache
|
|
136
|
+
path = @start_path
|
|
137
|
+
|
|
138
|
+
# Traverse up the directory tree
|
|
139
|
+
loop do
|
|
140
|
+
# Check for any marker in current directory
|
|
141
|
+
@markers.each do |marker|
|
|
142
|
+
marker_path = File.join(path, marker)
|
|
143
|
+
return path if File.exist?(marker_path)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Get parent directory
|
|
147
|
+
parent = File.dirname(path)
|
|
148
|
+
|
|
149
|
+
# Stop if we've reached the filesystem root
|
|
150
|
+
break if parent == path
|
|
151
|
+
|
|
152
|
+
path = parent
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def path_within_root?(candidate_path, root_path)
|
|
159
|
+
candidate = File.expand_path(candidate_path)
|
|
160
|
+
root = File.expand_path(root_path)
|
|
161
|
+
candidate == root || candidate.start_with?("#{root}/")
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "fs/version"
|
|
4
|
+
require_relative "fs/errors"
|
|
5
|
+
require_relative "fs/atoms/path_expander"
|
|
6
|
+
require_relative "fs/molecules/project_root_finder"
|
|
7
|
+
require_relative "fs/molecules/directory_traverser"
|
|
8
|
+
|
|
9
|
+
module Ace
|
|
10
|
+
module Support
|
|
11
|
+
# Filesystem utilities for ace-* gems
|
|
12
|
+
#
|
|
13
|
+
# Provides unified path expansion, project root detection, and directory traversal
|
|
14
|
+
# functionality extracted from ace-support-core and ace-config.
|
|
15
|
+
#
|
|
16
|
+
# Components:
|
|
17
|
+
# - Atoms::PathExpander - Path expansion with protocol, env var, and relative path support
|
|
18
|
+
# - Molecules::ProjectRootFinder - Project root detection based on marker files
|
|
19
|
+
# - Molecules::DirectoryTraverser - Config directory discovery in directory hierarchy
|
|
20
|
+
module Fs
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ace-support-fs
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.3.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Michal Czyz
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Infrastructure gem providing unified path expansion, project root detection,
|
|
13
|
+
and directory traversal functionality for ace-* gems. Library-only gem following
|
|
14
|
+
ace-support-* pattern for shared filesystem operations.
|
|
15
|
+
email:
|
|
16
|
+
- mc@cs3b.com
|
|
17
|
+
executables: []
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- CHANGELOG.md
|
|
22
|
+
- LICENSE
|
|
23
|
+
- README.md
|
|
24
|
+
- Rakefile
|
|
25
|
+
- lib/ace/support/fs.rb
|
|
26
|
+
- lib/ace/support/fs/atoms/path_expander.rb
|
|
27
|
+
- lib/ace/support/fs/errors.rb
|
|
28
|
+
- lib/ace/support/fs/molecules/directory_traverser.rb
|
|
29
|
+
- lib/ace/support/fs/molecules/project_root_finder.rb
|
|
30
|
+
- lib/ace/support/fs/version.rb
|
|
31
|
+
homepage: https://github.com/cs3b/ace
|
|
32
|
+
licenses:
|
|
33
|
+
- MIT
|
|
34
|
+
metadata:
|
|
35
|
+
allowed_push_host: https://rubygems.org
|
|
36
|
+
homepage_uri: https://github.com/cs3b/ace
|
|
37
|
+
source_code_uri: https://github.com/cs3b/ace/tree/main/ace-support-fs/
|
|
38
|
+
changelog_uri: https://github.com/cs3b/ace/blob/main/ace-support-fs/CHANGELOG.md
|
|
39
|
+
rdoc_options: []
|
|
40
|
+
require_paths:
|
|
41
|
+
- lib
|
|
42
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: 3.2.0
|
|
47
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
48
|
+
requirements:
|
|
49
|
+
- - ">="
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '0'
|
|
52
|
+
requirements: []
|
|
53
|
+
rubygems_version: 3.6.9
|
|
54
|
+
specification_version: 4
|
|
55
|
+
summary: Filesystem utilities for ace-* gems
|
|
56
|
+
test_files: []
|