Module: CovLoupe::PathUtils
- Defined in:
- lib/cov_loupe/paths/path_utils.rb
Overview
Centralized path handling utilities providing consistent normalization, relativization, and absolutization across all components.
All path operations in cov-loupe should go through this module to ensure consistent handling of:
- Platform differences (Windows backslashes, drive letters)
- Case sensitivity (detected per-volume at runtime)
- Relative vs absolute path resolution
Case sensitivity is derived from the volume containing the given path, not from a global setting. This is important for projects that span multiple volumes (e.g., project on macOS case-insensitive volume, resultset on a case-sensitive external drive).
Class Method Summary collapse
-
.absolute?(path) ⇒ Boolean
Checks if a path is absolute.
-
.basename(path, options = {}) ⇒ String
Extracts basename from a path, handling normalization.
-
.clear_volume_case_sensitivity_cache ⇒ void
Clears the volume case sensitivity cache (useful for testing).
-
.expand(path, base = nil) ⇒ String
Expands a path to absolute form, optionally relative to a base directory.
-
.join(*components) ⇒ String
Joins path components using platform-appropriate separators.
-
.normalize(path, options = {}) ⇒ String
Normalizes a path by handling: 1.
-
.normalized_start_with?(path, prefix, root: nil) ⇒ Boolean
Checks if a path starts with a prefix using normalized comparison to handle case-insensitive volumes and mixed separators.
-
.preserve_original_casing(relative_path, source_path, root_path) ⇒ String
Preserves original casing from the source path when creating a relative path.
-
.relative?(path) ⇒ Boolean
Checks if a path is relative.
-
.relativize(path, root) ⇒ String
Converts an absolute path to a path relative to the given root.
-
.root_prefix(root) ⇒ String
Returns root path with trailing separator for prefix matching.
-
.volume_case_sensitive?(path = nil) ⇒ Boolean
Detects whether the volume at the given path is case-sensitive.
-
.windows? ⇒ Boolean
Platform detection - delegates to main CovLoupe module for testability.
- .windows_drive? ⇒ Boolean
-
.within_root?(path, root) ⇒ Boolean
Checks if a path is within a given root directory.
Class Method Details
.absolute?(path) ⇒ Boolean
Checks if a path is absolute
138 139 140 141 142 143 144 145 |
# File 'lib/cov_loupe/paths/path_utils.rb', line 138 def self.absolute?(path) return false if path.nil? || path.empty? # Check for Windows drive paths (C:/, D:/, etc.) return true if path.match?(%r{^[A-Za-z]:[/\\]}) Pathname.new(path).absolute? end |
.basename(path, options = {}) ⇒ String
Extracts basename from a path, handling normalization
174 175 176 177 178 |
# File 'lib/cov_loupe/paths/path_utils.rb', line 174 def self.basename(path, = {}) return '' if path.nil? || path.empty? normalize(path, ).split('/').last end |
.clear_volume_case_sensitivity_cache ⇒ void
This method returns an undefined value.
Clears the volume case sensitivity cache (useful for testing)
200 201 202 |
# File 'lib/cov_loupe/paths/path_utils.rb', line 200 def self.clear_volume_case_sensitivity_cache VolumeCaseSensitivity.clear_cache end |
.expand(path, base = nil) ⇒ String
Expands a path to absolute form, optionally relative to a base directory
74 75 76 77 78 79 80 81 82 83 84 85 86 87 |
# File 'lib/cov_loupe/paths/path_utils.rb', line 74 def self.(path, base = nil) return path if path.nil? || path.empty? # On Windows, only bypass File.expand_path if path already has a drive letter. # Paths like "/foo" are considered absolute by absolute? but need File.expand_path # to acquire the current drive letter (e.g., "C:/foo"). if absolute?(path) && (!windows? || path.match?(/^[A-Za-z]:/)) # Use Pathname#cleanpath to preserve case on Windows, as File.expand_path # can sometimes canonicalize case for existing files. Pathname.new(path).cleanpath.to_s else base ? File.(path, base) : File.(path) end end |
.join(*components) ⇒ String
Joins path components using platform-appropriate separators
184 185 186 |
# File 'lib/cov_loupe/paths/path_utils.rb', line 184 def self.join(*components) File.join(*components) end |
.normalize(path, options = {}) ⇒ String
Normalizes a path by handling:
- Slash normalization (Windows backslashes -> forward slashes)
- Case normalization (case-insensitive volumes)
- Path cleaning (removing ., .., redundant separators)
40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 |
# File 'lib/cov_loupe/paths/path_utils.rb', line 40 def self.normalize(path, = {}) return path if path.nil? || path.empty? result = path.to_s # Always normalize slashes on Windows (Pathname#cleanpath does this anyway) result = result.tr('\\', '/') if windows? # Handle case normalization for case-insensitive volumes # If root is provided, derive case-sensitivity from root's volume root = [:root] begin default_normalize_case = if root !VolumeCaseSensitivity.volume_case_sensitive?(root) else !VolumeCaseSensitivity.volume_case_sensitive? end rescue SystemCallError, IOError # If we can't detect case sensitivity, assume case-insensitive to be conservative default_normalize_case = true end if .fetch(:normalize_case, default_normalize_case) result = result.downcase end # Clean path components Pathname.new(result).cleanpath.to_s end |
.normalized_start_with?(path, prefix, root: nil) ⇒ Boolean
Checks if a path starts with a prefix using normalized comparison to handle case-insensitive volumes and mixed separators
247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 |
# File 'lib/cov_loupe/paths/path_utils.rb', line 247 def self.normalized_start_with?(path, prefix, root: nil) return false if path.nil? || prefix.nil? || prefix.empty? # Normalize both paths for comparison (case + separators) # If root is provided, derive case-sensitivity from root's volume case_sensitive = begin if root VolumeCaseSensitivity.volume_case_sensitive?(root) else VolumeCaseSensitivity.volume_case_sensitive? end rescue SystemCallError, IOError # If we can't detect case sensitivity, assume case-insensitive to be conservative false end normalized_path = normalize(path, normalize_case: !case_sensitive, root: root) normalized_prefix = normalize(prefix, normalize_case: !case_sensitive, root: root) # Check if normalized path starts with normalized prefix # AND ensure we have proper path boundary (either exact match or followed by separator) return false unless normalized_path.start_with?(normalized_prefix) # If exact match, return true return true if normalized_path == normalized_prefix # Otherwise, ensure character after prefix is a path separator # (normalize converts all backslashes to forward slashes, so only check for /) prefix_length = normalized_prefix.length normalized_path[prefix_length] == '/' end |
.preserve_original_casing(relative_path, source_path, root_path) ⇒ String
Preserves original casing from the source path when creating a relative path
220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 |
# File 'lib/cov_loupe/paths/path_utils.rb', line 220 def self.preserve_original_casing(relative_path, source_path, root_path) # Split paths into components relative_components = relative_path.split('/') source_components = normalize(source_path, normalize_case: false, root: root_path).split('/') root_components = normalize(root_path, normalize_case: false, root: root_path).split('/') # Skip root components to get to the relative part relative_start_index = root_components.length # relative_path and the tail of source_path have the same component count because # relative_path was derived from source_path relative to root_path just above. # Map each normalized component back to its original casing original_components = relative_components.map.with_index do |_component, index| source_index = relative_start_index + index source_components[source_index] || relative_components[index] end original_components.join('/') end |
.relative?(path) ⇒ Boolean
Checks if a path is relative
151 152 153 |
# File 'lib/cov_loupe/paths/path_utils.rb', line 151 def self.relative?(path) !absolute?(path) end |
.relativize(path, root) ⇒ String
Converts an absolute path to a path relative to the given root
94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 |
# File 'lib/cov_loupe/paths/path_utils.rb', line 94 def self.relativize(path, root) return path if path.nil? || path.empty? || root.nil? || root.empty? # Only expand relative paths against root; absolute paths expand without base abs_path = absolute?(path) ? (path) : (path, root) abs_root = (root) # Check if path is within root using normalized comparison # Derive case-sensitivity from root's volume for accurate cross-volume handling return path unless normalized_start_with?(abs_path, abs_root, root: abs_root) # Normalize paths before relative_path_from so Pathname can compute the relative path # regardless of mixed separators or case differences. Case-sensitivity is derived from # root's volume. case_sensitive = begin VolumeCaseSensitivity.volume_case_sensitive?(abs_root) rescue SystemCallError, IOError # If we can't detect case sensitivity, assume case-insensitive to be conservative false end normalized_path = normalize(abs_path, normalize_case: !case_sensitive, root: abs_root) normalized_root = normalize(abs_root, normalize_case: !case_sensitive, root: abs_root) relative = Pathname.new(normalized_path) .relative_path_from(Pathname.new(normalized_root)) .to_s # Pathname computes the relative path from the normalized strings, which may all be # lowercase on case-insensitive volumes. Rebuild the result from abs_path so reports # keep the casing the user actually sees on disk. if !case_sensitive && relative != '.' preserve_original_casing(relative, abs_path, abs_root) else relative end rescue ArgumentError # Path is on a different drive or cannot be made relative path end |
.root_prefix(root) ⇒ String
Returns root path with trailing separator for prefix matching
208 209 210 211 212 |
# File 'lib/cov_loupe/paths/path_utils.rb', line 208 def self.root_prefix(root) return '' if root.nil? || root.empty? root.end_with?(File::SEPARATOR) ? root : "#{root}#{File::SEPARATOR}" end |
.volume_case_sensitive?(path = nil) ⇒ Boolean
Detects whether the volume at the given path is case-sensitive. Delegates to VolumeCaseSensitivity module for implementation.
193 194 195 |
# File 'lib/cov_loupe/paths/path_utils.rb', line 193 def self.volume_case_sensitive?(path = nil) VolumeCaseSensitivity.volume_case_sensitive?(path) end |
.windows? ⇒ Boolean
Platform detection - delegates to main CovLoupe module for testability
22 23 24 |
# File 'lib/cov_loupe/paths/path_utils.rb', line 22 def self.windows? CovLoupe.windows? end |
.windows_drive? ⇒ Boolean
26 27 28 |
# File 'lib/cov_loupe/paths/path_utils.rb', line 26 def self.windows_drive? File.('.').match?(/^[A-Za-z]:/) end |
.within_root?(path, root) ⇒ Boolean
Checks if a path is within a given root directory
160 161 162 163 164 165 166 167 |
# File 'lib/cov_loupe/paths/path_utils.rb', line 160 def self.within_root?(path, root) return false if path.nil? || root.nil? abs_path = (path) abs_root = (root) normalized_start_with?(abs_path, abs_root, root: abs_root) end |