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

Class Method Details

.absolute?(path) ⇒ Boolean

Checks if a path is absolute

Parameters:

  • path (String)

    path to check

Returns:

  • (Boolean)

    true if 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

Parameters:

  • path (String)

    path to extract basename from

  • options (Hash) (defaults to: {})

    options passed to normalize

Returns:

  • (String)

    basename



174
175
176
177
178
# File 'lib/cov_loupe/paths/path_utils.rb', line 174

def self.basename(path, options = {})
  return '' if path.nil? || path.empty?

  normalize(path, options).split('/').last
end

.clear_volume_case_sensitivity_cachevoid

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

Parameters:

  • path (String)

    path to expand

  • base (String, nil) (defaults to: nil)

    base directory (defaults to current working directory)

Returns:

  • (String)

    absolute path



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.expand(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.expand_path(path, base) : File.expand_path(path)
  end
end

.join(*components) ⇒ String

Joins path components using platform-appropriate separators

Parameters:

  • components (Array<String>)

    path components

Returns:

  • (String)

    joined path



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:

  1. Slash normalization (Windows backslashes -> forward slashes)
  2. Case normalization (case-insensitive volumes)
  3. Path cleaning (removing ., .., redundant separators)

Parameters:

  • path (String, Pathname)

    path to normalize

  • options (Hash) (defaults to: {})

    normalization options

Options Hash (options):

  • :normalize_case (Boolean) — default: true on case-insensitive volumes
  • :root (String) — default: nil

    root directory for determining volume case-sensitivity

Returns:

  • (String)

    normalized path



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, options = {})
  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 = options[: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 options.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

Parameters:

  • path (String)

    path to check

  • prefix (String)

    prefix to match against

  • root (String, nil) (defaults to: nil)

    root directory for determining volume case-sensitivity

Returns:

  • (Boolean)

    true if path starts with prefix (after normalization)



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

Parameters:

  • relative_path (String)

    normalized relative path

  • source_path (String)

    original source path with original casing

  • root_path (String)

    root path

Returns:

  • (String)

    relative path with original casing preserved



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

Parameters:

  • path (String)

    path to check

Returns:

  • (Boolean)

    true if 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

Parameters:

  • path (String)

    absolute path to relativize

  • root (String)

    root directory for relativization

Returns:

  • (String)

    relative path or original path if conversion fails



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) ? expand(path) : expand(path, root)
  abs_root = expand(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

Parameters:

  • root (String)

    root path

Returns:

  • (String)

    root with trailing separator



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.

Parameters:

  • path (String, nil) (defaults to: nil)

    directory path to test (defaults to current directory)

Returns:

  • (Boolean)

    true if case-sensitive, false if case-insensitive or on error



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

Returns:

  • (Boolean)


22
23
24
# File 'lib/cov_loupe/paths/path_utils.rb', line 22

def self.windows?
  CovLoupe.windows?
end

.windows_drive?Boolean

Returns:

  • (Boolean)


26
27
28
# File 'lib/cov_loupe/paths/path_utils.rb', line 26

def self.windows_drive?
  File.expand_path('.').match?(/^[A-Za-z]:/)
end

.within_root?(path, root) ⇒ Boolean

Checks if a path is within a given root directory

Parameters:

  • path (String)

    path to check

  • root (String)

    root directory

Returns:

  • (Boolean)

    true if path is within root



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 = expand(path)
  abs_root = expand(root)

  normalized_start_with?(abs_path, abs_root, root: abs_root)
end