kettle-paths 0.1.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
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +130 -0
- data/LICENSE.md +10 -0
- data/README.md +514 -0
- data/lib/kettle/paths/version.rb +13 -0
- data/lib/kettle/paths.rb +397 -0
- data/sig/kettle/paths.rbs +78 -0
- data.tar.gz.sig +3 -0
- metadata +321 -0
- metadata.gz.sig +0 -0
data/lib/kettle/paths.rb
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "version_gem"
|
|
5
|
+
require_relative "paths/version"
|
|
6
|
+
|
|
7
|
+
module Kettle
|
|
8
|
+
# Filesystem path comparison by identity rather than by spelling.
|
|
9
|
+
#
|
|
10
|
+
# == Why string comparison of paths is not safe
|
|
11
|
+
#
|
|
12
|
+
# A path string is a *name*, not an identity. One directory can have several
|
|
13
|
+
# valid names, and two names can reach the same code from different APIs
|
|
14
|
+
# within one process. Two cases bite these tools on every platform:
|
|
15
|
+
#
|
|
16
|
+
# 1. A symlinked ancestor. On Fedora Silverblue/Aurora +/home/x+ is a symlink
|
|
17
|
+
# to +/var/home/x+, so +Dir.pwd+ can report one form while a path built
|
|
18
|
+
# from an ENV var, a stored fixture, a TMPDIR, or
|
|
19
|
+
# +git rev-parse --show-toplevel+ reports the other.
|
|
20
|
+
#
|
|
21
|
+
# 2. Windows 8.3 short names. Measured on a GitHub Actions windows-latest
|
|
22
|
+
# runner:
|
|
23
|
+
#
|
|
24
|
+
# Dir.pwd => "C:/Users/RUNNER~1/AppData/Local/Temp/turbo-tests2-..."
|
|
25
|
+
# ENV["TEMP"] => "C:\\Users\\RUNNER~1\\AppData\\Local\\Temp"
|
|
26
|
+
# Dir.glob result => "C:/Users/runneradmin/AppData/Local/Temp/turbo-tests2-..."
|
|
27
|
+
#
|
|
28
|
+
# +RUNNER~1+ is the 8.3 short name and +runneradmin+ the long name of the
|
|
29
|
+
# same directory. +Dir.pwd+ and the environment report the short form while
|
|
30
|
+
# +Dir.glob+ — which RSpec uses to expand +--pattern+ — reports the long
|
|
31
|
+
# form. Both are correct and they are never equal as strings.
|
|
32
|
+
#
|
|
33
|
+
# The concrete damage: +Pathname#relative_path_from+ is purely lexical, so
|
|
34
|
+
# with differing spellings it walks all the way up and back down and returns a
|
|
35
|
+
# plausible-looking escape such as
|
|
36
|
+
# +"../../../../../runneradmin/AppData/Local/Temp/..."+. It does not raise, so
|
|
37
|
+
# a +rescue ArgumentError+ cannot catch it. Those escapes then reach
|
|
38
|
+
# +git add+, which fails with "is outside repository at", or a test runner
|
|
39
|
+
# that silently selects the wrong files.
|
|
40
|
+
#
|
|
41
|
+
# == The approach that works
|
|
42
|
+
#
|
|
43
|
+
# +File.identical?+ asks the filesystem whether two names denote one object —
|
|
44
|
+
# by inode on Unix and by file index on Windows. It is spelling-agnostic,
|
|
45
|
+
# which is exactly the property needed. So containment and relativization here
|
|
46
|
+
# ascend the candidate path asking the filesystem "is this the root?", and
|
|
47
|
+
# re-join basenames on the way back down.
|
|
48
|
+
#
|
|
49
|
+
# Lexical component comparison is kept only as a fallback for paths that do
|
|
50
|
+
# not exist yet, which have no identity to ask about. That is what keeps the
|
|
51
|
+
# comparison useful for generated output paths.
|
|
52
|
+
#
|
|
53
|
+
# == Approaches that do NOT work, and why
|
|
54
|
+
#
|
|
55
|
+
# All four were tried against the real failing environments and all failed;
|
|
56
|
+
# recorded so the sequence is not repeated.
|
|
57
|
+
#
|
|
58
|
+
# 1. String prefix (+expanded.start_with?("#{root}/")+): never matches when
|
|
59
|
+
# the two spellings differ, so absolute temp paths leak through to
|
|
60
|
+
# consumers that then stat them. It also wrongly reports "project-other"
|
|
61
|
+
# as inside "project".
|
|
62
|
+
#
|
|
63
|
+
# 2. Canonicalize with +File.realpath+, then compare: WRONG PREMISE —
|
|
64
|
+
# +File.realpath+ does NOT expand Windows 8.3 short names. Measured on the
|
|
65
|
+
# runner, realpath of the short path returns the short path unchanged, so
|
|
66
|
+
# canonicalization cannot reconcile the two spellings at all. It also
|
|
67
|
+
# resolves symlinks, which adds a second failure mode: root and file may
|
|
68
|
+
# legitimately traverse different symlink chains to one directory, and
|
|
69
|
+
# canonicalizing only one side makes them diverge.
|
|
70
|
+
#
|
|
71
|
+
# 3. Component-wise comparison of canonicalized parts: same false premise as
|
|
72
|
+
# #2, so +RUNNER~1+ never equals +runneradmin+ under any case folding. It
|
|
73
|
+
# also relied on +File::ALT_SEPARATOR+, which measured as +""+ (empty
|
|
74
|
+
# string, not nil) on that runner, making the separator-normalizing +tr+ a
|
|
75
|
+
# silent no-op. Do not trust ALT_SEPARATOR to be present.
|
|
76
|
+
#
|
|
77
|
+
# 4. +Pathname#relative_path_from+: purely lexical, as described above.
|
|
78
|
+
#
|
|
79
|
+
# == How the Windows case was finally diagnosed
|
|
80
|
+
#
|
|
81
|
+
# It only reproduced on the Windows CI runner, and the runner's output was
|
|
82
|
+
# swallowed: the discovery specs invoke a subprocess whose stderr surfaces
|
|
83
|
+
# only when a status check fails, and the assertion that failed was a JSON
|
|
84
|
+
# comparison. Four blind fixes failed in a row. What resolved it was a
|
|
85
|
+
# temporary, Windows-guarded spec (+if: Gem.win_platform?+) that deliberately
|
|
86
|
+
# failed with the diagnostic JSON embedded in the expectation message, so the
|
|
87
|
+
# runner's real path forms printed into the CI log. Guarding it kept the other
|
|
88
|
+
# checks green while one job reported the data; that spec was then deleted and
|
|
89
|
+
# its findings became this documentation plus the specs in this gem.
|
|
90
|
+
#
|
|
91
|
+
# Lesson: when a bug is environment-specific and the environment is not
|
|
92
|
+
# available locally, spend the iteration on instrumenting the real environment
|
|
93
|
+
# rather than on another hypothesized fix.
|
|
94
|
+
#
|
|
95
|
+
# == Dependency-free by design
|
|
96
|
+
#
|
|
97
|
+
# No runtime dependencies and MIT licensed, so it can be shared by both MIT
|
|
98
|
+
# and AGPL tooling without license friction. Keep it that way.
|
|
99
|
+
#
|
|
100
|
+
# The Ruby floor is 2.4.0 to match its consumers (kettle-dev, kettle-family and
|
|
101
|
+
# turbo_tests2 all declare ">= 2.4.0"), so this file avoids +filter_map+
|
|
102
|
+
# (2.7+), multi-argument +String#start_with?+ (2.5+), +Hash#to_h+ with a block
|
|
103
|
+
# (2.6+) and endless method definitions.
|
|
104
|
+
module Paths
|
|
105
|
+
# Raised for programmer error only. Path operations here return nil or false
|
|
106
|
+
# rather than raising, because a missing or unreadable path is a normal
|
|
107
|
+
# condition in these tools.
|
|
108
|
+
class Error < StandardError; end
|
|
109
|
+
|
|
110
|
+
module_function
|
|
111
|
+
|
|
112
|
+
LOCAL_REMOTE_PREFIXES = ["/", "./", "../", ".\\", "..\\"].freeze
|
|
113
|
+
|
|
114
|
+
# Expands +path+ and resolves its longest existing ancestor, re-attaching
|
|
115
|
+
# any non-existent suffix. Keeps comparisons useful for generated paths that
|
|
116
|
+
# do not exist yet, which is common in templating and release tooling.
|
|
117
|
+
#
|
|
118
|
+
# NOTE: this does NOT make differently spelled paths comparable as strings —
|
|
119
|
+
# see the class documentation on +File.realpath+ and 8.3 short names. Use
|
|
120
|
+
# {same?}, {within?} or {relative} to compare, never string equality on the
|
|
121
|
+
# result of this method.
|
|
122
|
+
#
|
|
123
|
+
# @param path [String, Pathname, nil]
|
|
124
|
+
# @param base [String, nil] directory to expand a relative +path+ against
|
|
125
|
+
# @return [String, nil] the expanded path, or the plain expansion when the
|
|
126
|
+
# real path cannot be resolved; nil when +path+ is blank
|
|
127
|
+
def canonical(path, base: nil)
|
|
128
|
+
return nil if blank?(path)
|
|
129
|
+
|
|
130
|
+
expanded = File.expand_path(path.to_s, base)
|
|
131
|
+
existing = expanded
|
|
132
|
+
suffix = []
|
|
133
|
+
until File.exist?(existing) || File.symlink?(existing)
|
|
134
|
+
parent = File.dirname(existing)
|
|
135
|
+
return expanded if parent == existing
|
|
136
|
+
|
|
137
|
+
suffix.unshift(File.basename(existing))
|
|
138
|
+
existing = parent
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
suffix.reduce(File.realpath(existing)) { |resolved, component| File.join(resolved, component) }
|
|
142
|
+
rescue Errno::EACCES, Errno::ENOENT
|
|
143
|
+
expanded
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# True when +left+ and +right+ name the same filesystem object, compared by
|
|
147
|
+
# identity rather than by spelling.
|
|
148
|
+
#
|
|
149
|
+
# @param left [String, Pathname, nil]
|
|
150
|
+
# @param right [String, Pathname, nil]
|
|
151
|
+
# @param base [String, nil] directory to expand relative arguments against
|
|
152
|
+
# @return [Boolean] false when either argument is blank
|
|
153
|
+
def same?(left, right, base: nil)
|
|
154
|
+
return false if blank?(left) || blank?(right)
|
|
155
|
+
|
|
156
|
+
left_path = canonical(left, base: base)
|
|
157
|
+
right_path = canonical(right, base: base)
|
|
158
|
+
return false if blank?(left_path) || blank?(right_path)
|
|
159
|
+
return true if left_path == right_path
|
|
160
|
+
|
|
161
|
+
identical?(left_path, right_path) || comparable_parts(left_path) == comparable_parts(right_path)
|
|
162
|
+
rescue Errno::EACCES, Errno::ENOENT, Errno::ENOTDIR, NotImplementedError
|
|
163
|
+
comparable_parts(left_path || left.to_s) == comparable_parts(right_path || right.to_s)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Filesystem-identity comparison that tolerates a missing path.
|
|
167
|
+
#
|
|
168
|
+
# +File.identical?+ is not consistent across Rubies here: some raise
|
|
169
|
+
# +SystemCallError+ for a path that does not exist, while others (measured on
|
|
170
|
+
# Ruby 4.0.7) return false. Both are handled, because callers pass paths that
|
|
171
|
+
# may not exist yet — generated output, a lockfile entry for a file about to
|
|
172
|
+
# be written.
|
|
173
|
+
#
|
|
174
|
+
# The fallback is string equality, which is the correct answer when neither
|
|
175
|
+
# side is a symlink or an 8.3 short name. Rescues +SystemCallError+ rather
|
|
176
|
+
# than +Errno::ENOENT+ alone, because ENOTDIR, ELOOP and EACCES all mean
|
|
177
|
+
# "identity unknown" and the string fallback is the safest answer in each
|
|
178
|
+
# case.
|
|
179
|
+
#
|
|
180
|
+
# @param left [String, Pathname, nil]
|
|
181
|
+
# @param right [String, Pathname, nil]
|
|
182
|
+
# @return [Boolean]
|
|
183
|
+
def identical?(left, right)
|
|
184
|
+
return false if blank?(left) || blank?(right)
|
|
185
|
+
|
|
186
|
+
left_s = left.to_s
|
|
187
|
+
right_s = right.to_s
|
|
188
|
+
File.identical?(left_s, right_s)
|
|
189
|
+
rescue SystemCallError
|
|
190
|
+
left_s == right_s
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# True when +path+ is +root+ itself or lives inside it, compared by
|
|
194
|
+
# filesystem identity.
|
|
195
|
+
#
|
|
196
|
+
# Unlike a string prefix test this does not treat a sibling directory that
|
|
197
|
+
# merely shares a prefix ("project-other" vs "project") as being inside
|
|
198
|
+
# "project". Unlike lexical component comparison it survives a symlinked
|
|
199
|
+
# ancestor and Windows 8.3 short names.
|
|
200
|
+
#
|
|
201
|
+
# When identity cannot be established, because one side does not exist, this
|
|
202
|
+
# falls back to lexical component comparison. That is what keeps it usable
|
|
203
|
+
# for generated output paths that have not been written yet.
|
|
204
|
+
#
|
|
205
|
+
# @param path [String, Pathname, nil]
|
|
206
|
+
# @param root [String, Pathname, nil]
|
|
207
|
+
# @param base [String, nil]
|
|
208
|
+
# @return [Boolean] false when either argument is blank
|
|
209
|
+
def within?(path, root, base: nil)
|
|
210
|
+
return false if blank?(path) || blank?(root)
|
|
211
|
+
|
|
212
|
+
candidate = File.expand_path(path.to_s, base)
|
|
213
|
+
boundary = File.expand_path(root.to_s, base)
|
|
214
|
+
return true if identical?(candidate, boundary)
|
|
215
|
+
|
|
216
|
+
!relative_parts(candidate, boundary).nil? || lexical_within?(candidate, boundary)
|
|
217
|
+
rescue Errno::EACCES, Errno::ENOENT, Errno::ENOTDIR
|
|
218
|
+
false
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# The path of +path+ relative to +root+, computed by ascending +path+ and
|
|
222
|
+
# asking the filesystem at each level whether that ancestor is +root+.
|
|
223
|
+
#
|
|
224
|
+
# Returns nil when +root+ is not an ancestor of +path+. Callers must not
|
|
225
|
+
# substitute a lexical result for that nil: +Pathname#relative_path_from+
|
|
226
|
+
# returns a plausible but wrong +../../..+ escape in exactly that case, which
|
|
227
|
+
# is worse than nil because it silently selects the wrong files or fails
|
|
228
|
+
# later inside +git add+.
|
|
229
|
+
#
|
|
230
|
+
# The returned string is built from +path+'s own spelling, which is what
|
|
231
|
+
# callers need when the result is stat'd or handed to a process spawned from
|
|
232
|
+
# this working directory. For that reason the path is expanded WITHOUT
|
|
233
|
+
# +File.realpath+: a mid-path symlink must not be silently rewritten to its
|
|
234
|
+
# target, because that changes which files a glob or a test runner sees.
|
|
235
|
+
# Identity comparison via {identical?} already reconciles differing ancestor
|
|
236
|
+
# spellings, so realpath buys nothing here and costs the caller's spelling.
|
|
237
|
+
# See the class documentation on +File.realpath+.
|
|
238
|
+
#
|
|
239
|
+
# @param path [String, Pathname]
|
|
240
|
+
# @param root [String, Pathname]
|
|
241
|
+
# @return [String, nil] relative path using forward slashes, "." when the two
|
|
242
|
+
# name the same directory, or nil when +root+ is not an ancestor
|
|
243
|
+
def relative(path, root)
|
|
244
|
+
return nil if blank?(path) || blank?(root)
|
|
245
|
+
|
|
246
|
+
candidate = File.expand_path(path.to_s)
|
|
247
|
+
boundary = File.expand_path(root.to_s)
|
|
248
|
+
return "." if identical?(candidate, boundary)
|
|
249
|
+
|
|
250
|
+
relative_parts(candidate, boundary)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# {relative}, falling back to the expanded +path+ when +root+ is not an
|
|
254
|
+
# ancestor, i.e. when there is nothing to strip. This is the shape spec
|
|
255
|
+
# discovery needs: it passes through paths outside the configuration root
|
|
256
|
+
# untouched rather than producing a wrong relative path.
|
|
257
|
+
#
|
|
258
|
+
# @param path [String, Pathname]
|
|
259
|
+
# @param root [String, Pathname]
|
|
260
|
+
# @return [String] relative path, "." for the same directory, or the expanded
|
|
261
|
+
# +path+ when it is not inside +root+
|
|
262
|
+
def relative_from(path, root)
|
|
263
|
+
expanded = File.expand_path(path.to_s)
|
|
264
|
+
relative(expanded, root) || expanded
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Dir.glob with forward-slash normalization, so patterns assembled from
|
|
268
|
+
# Windows paths still match.
|
|
269
|
+
#
|
|
270
|
+
# @param parts [Array<String>] joined with +File::SEPARATOR+ before globbing
|
|
271
|
+
# @return [Array<String>]
|
|
272
|
+
def glob(*parts)
|
|
273
|
+
# Windows 8.3 short names (RUNNER~1) hide that two spellings are one
|
|
274
|
+
# file, and File.expand_path does NOT resolve them: the base may stay
|
|
275
|
+
# short-named while Dir.glob results come back long-named (or vice
|
|
276
|
+
# versa). Compare both sides in expanded space to compute the relative
|
|
277
|
+
# path, then re-prefix with the caller's original spelling, so results
|
|
278
|
+
# spell the base exactly as the caller did and comparisons against
|
|
279
|
+
# TMPDIR-derived expectations are spelling-independent.
|
|
280
|
+
pattern = File.join(*parts).tr("\\", "/")
|
|
281
|
+
original_base = pattern.split("/")[0..-2].join("/")
|
|
282
|
+
original_base = original_base.empty? ? "." : original_base
|
|
283
|
+
expanded_base = File.expand_path(original_base)
|
|
284
|
+
base_prefix = "#{expanded_base}/"
|
|
285
|
+
results = Dir.glob([pattern, File.join(expanded_base, File.basename(pattern))].uniq)
|
|
286
|
+
results.map do |result|
|
|
287
|
+
expanded_result = File.expand_path(result)
|
|
288
|
+
relative = expanded_result.start_with?(base_prefix) ? expanded_result.delete_prefix(base_prefix) : File.basename(result)
|
|
289
|
+
File.join(original_base, relative)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# True when a bundler/lockfile "remote:" value denotes a local path rather
|
|
294
|
+
# than a registry URL.
|
|
295
|
+
#
|
|
296
|
+
# @param remote [String, nil]
|
|
297
|
+
# @return [Boolean]
|
|
298
|
+
def local_path_remote?(remote)
|
|
299
|
+
text = remote.to_s
|
|
300
|
+
return false if text.empty?
|
|
301
|
+
|
|
302
|
+
# Windows drive-absolute: "C:/x" or "C:\x"
|
|
303
|
+
third_char = text[2].to_s
|
|
304
|
+
drive_absolute = text.length >= 3 && text[1] == ":" && (third_char == "/" || third_char == "\\")
|
|
305
|
+
drive_absolute ||
|
|
306
|
+
LOCAL_REMOTE_PREFIXES.any? { |prefix| text.start_with?(prefix) } ||
|
|
307
|
+
absolute_pathname?(text)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Splits a path into its components, normalizing backslashes so Windows and
|
|
311
|
+
# POSIX paths compare alike.
|
|
312
|
+
#
|
|
313
|
+
# @param path [String, Pathname, nil]
|
|
314
|
+
# @return [Array<String>]
|
|
315
|
+
def path_parts(path)
|
|
316
|
+
path.to_s.tr("\\", "/").split("/").reject(&:empty?)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# {path_parts}, case-folded on Windows where the filesystem is
|
|
320
|
+
# case-insensitive.
|
|
321
|
+
#
|
|
322
|
+
# @param path [String, Pathname, nil]
|
|
323
|
+
# @return [Array<String>]
|
|
324
|
+
def comparable_parts(path)
|
|
325
|
+
parts = path_parts(path)
|
|
326
|
+
windows? ? parts.map(&:downcase) : parts
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# @api private
|
|
330
|
+
def windows?
|
|
331
|
+
Gem.win_platform?
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# @api private
|
|
335
|
+
def blank?(value)
|
|
336
|
+
value.nil? || value.to_s.empty?
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# @api private
|
|
340
|
+
def absolute_pathname?(text)
|
|
341
|
+
Pathname.new(text).absolute?
|
|
342
|
+
rescue
|
|
343
|
+
false
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Lexical containment, used only when identity cannot be established because
|
|
347
|
+
# one side does not exist. Component-wise rather than a string prefix, so a
|
|
348
|
+
# sibling sharing a prefix is not reported as contained.
|
|
349
|
+
#
|
|
350
|
+
# @api private
|
|
351
|
+
def lexical_within?(candidate, boundary)
|
|
352
|
+
candidate_parts = comparable_parts(candidate)
|
|
353
|
+
boundary_parts = comparable_parts(boundary)
|
|
354
|
+
boundary_length = boundary_parts.length
|
|
355
|
+
return false if candidate_parts.length < boundary_length
|
|
356
|
+
|
|
357
|
+
candidate_parts[0, boundary_length] == boundary_parts
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Ascend from +candidate+ asking the filesystem whether each ancestor is
|
|
361
|
+
# +boundary+, re-joining basenames on the way back down.
|
|
362
|
+
#
|
|
363
|
+
# Recursion is avoided in favour of a loop: +File.dirname+ of the filesystem
|
|
364
|
+
# root returns itself ("C:/" or "/"), so the +parent == current+ check ends
|
|
365
|
+
# every walk without needing a depth constant. Depth is bounded by path
|
|
366
|
+
# length and the cost is one stat pair per level.
|
|
367
|
+
#
|
|
368
|
+
# Both arguments are expected to be canonicalized already, by the public
|
|
369
|
+
# entry points, so the loop compares with {identical?} rather than paying
|
|
370
|
+
# for a fresh {canonical} per level.
|
|
371
|
+
#
|
|
372
|
+
# @return [String, nil] relative path, "." for the same directory, or nil
|
|
373
|
+
# when the boundary was never reached
|
|
374
|
+
# @api private
|
|
375
|
+
def relative_parts(candidate, boundary)
|
|
376
|
+
directory = File.directory?(candidate)
|
|
377
|
+
current = directory ? candidate : File.dirname(candidate)
|
|
378
|
+
parts = directory ? [] : [File.basename(candidate)]
|
|
379
|
+
|
|
380
|
+
until identical?(current, boundary)
|
|
381
|
+
parent = File.dirname(current)
|
|
382
|
+
return nil if parent == current
|
|
383
|
+
|
|
384
|
+
parts.unshift(File.basename(current))
|
|
385
|
+
current = parent
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
return "." if parts.empty?
|
|
389
|
+
|
|
390
|
+
File.join(*parts).tr("\\", "/")
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
Kettle::Paths::Version.class_eval do
|
|
396
|
+
extend VersionGem::Basic
|
|
397
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
module Kettle
|
|
2
|
+
module Paths
|
|
3
|
+
VERSION: String
|
|
4
|
+
|
|
5
|
+
module Version
|
|
6
|
+
VERSION: String
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Raised for programmer error only; the path operations return nil or false
|
|
10
|
+
# rather than raising, because a missing or unreadable path is a normal
|
|
11
|
+
# condition in these tools.
|
|
12
|
+
class Error < StandardError
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
LOCAL_REMOTE_PREFIXES: Array[String]
|
|
16
|
+
|
|
17
|
+
# Expands a path and resolves its longest existing ancestor, re-attaching any
|
|
18
|
+
# non-existent suffix. Returns nil for a blank path.
|
|
19
|
+
#
|
|
20
|
+
# Does NOT make differently spelled paths comparable as strings; use
|
|
21
|
+
# `same?` / `within?` / `relative` to compare.
|
|
22
|
+
def self.canonical: (String | Pathname | nil path, ?base: String | nil) -> String?
|
|
23
|
+
|
|
24
|
+
# True when both arguments name the same filesystem object, compared by
|
|
25
|
+
# identity rather than by spelling. False when either argument is blank.
|
|
26
|
+
def self.same?: (untyped left, untyped right, ?base: String | nil) -> bool
|
|
27
|
+
|
|
28
|
+
# Filesystem-identity comparison that tolerates a missing path, falling back
|
|
29
|
+
# to string equality when identity is unknown.
|
|
30
|
+
def self.identical?: (untyped left, untyped right) -> bool
|
|
31
|
+
|
|
32
|
+
# True when path is root itself or lives inside it, compared by filesystem
|
|
33
|
+
# identity. Falls back to lexical component comparison when identity cannot
|
|
34
|
+
# be established, which is what keeps it usable for generated output paths.
|
|
35
|
+
def self.within?: (untyped path, untyped root, ?base: String | nil) -> bool
|
|
36
|
+
|
|
37
|
+
# Path of `path` relative to `root`, computed by ascending `path` and asking
|
|
38
|
+
# the filesystem at each level whether that ancestor is `root`.
|
|
39
|
+
#
|
|
40
|
+
# Returns "." when they name the same directory and nil when root is not an
|
|
41
|
+
# ancestor. Never returns a lexical parent-directory escape, which is what
|
|
42
|
+
# `Pathname#relative_path_from` produces for differently spelled paths.
|
|
43
|
+
def self.relative: (untyped path, untyped root) -> String?
|
|
44
|
+
|
|
45
|
+
# `relative`, falling back to the expanded path when root is not an ancestor,
|
|
46
|
+
# i.e. when there is nothing to strip.
|
|
47
|
+
def self.relative_from: (untyped path, untyped root) -> String
|
|
48
|
+
|
|
49
|
+
# Dir.glob with forward-slash normalization, so patterns assembled from
|
|
50
|
+
# Windows paths still match.
|
|
51
|
+
def self.glob: (*untyped parts) -> Array[String]
|
|
52
|
+
|
|
53
|
+
# True when a bundler/lockfile "remote:" value denotes a local path rather
|
|
54
|
+
# than a registry URL.
|
|
55
|
+
def self.local_path_remote?: (untyped remote) -> bool
|
|
56
|
+
|
|
57
|
+
# Path components, normalizing backslashes so Windows and POSIX compare alike.
|
|
58
|
+
def self.path_parts: (untyped path) -> Array[String]
|
|
59
|
+
|
|
60
|
+
# `path_parts`, case-folded on Windows where the filesystem is
|
|
61
|
+
# case-insensitive.
|
|
62
|
+
def self.comparable_parts: (untyped path) -> Array[String]
|
|
63
|
+
|
|
64
|
+
def self.windows?: () -> bool
|
|
65
|
+
|
|
66
|
+
def self.blank?: (untyped value) -> bool
|
|
67
|
+
|
|
68
|
+
def self.absolute_pathname?: (String text) -> bool
|
|
69
|
+
|
|
70
|
+
# Lexical containment, used only when identity cannot be established because
|
|
71
|
+
# one side does not exist.
|
|
72
|
+
def self.lexical_within?: (untyped candidate, untyped boundary) -> bool
|
|
73
|
+
|
|
74
|
+
# Ascends from candidate asking the filesystem whether each ancestor is
|
|
75
|
+
# boundary, re-joining basenames on the way back down.
|
|
76
|
+
def self.relative_parts: (untyped candidate, untyped boundary) -> String?
|
|
77
|
+
end
|
|
78
|
+
end
|
data.tar.gz.sig
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
C%�d:��� RJ�o��E4���Ҁ�%
|
|
2
|
+
�|�[���lwdPS���v��`���.9R����KK�[��v5��,ܶ~��B�0;7H��X����`��q������������\����';�i���������xM�����w�gw�e�wh�{�_� 49��W��mo�����|���/x���,d%m����P�*������fV<k! �3������3��S (jϿᱜ}z"�ڙF���-�(ѡT�0�Ş�s��;6 �=769���x(�0����8�9�LFaq{?�Vi��|=�!��|��&hg�H����*%iEI�h
|
|
3
|
+
�T$5���Z�KX7�-mĘsH�k.�yC�����9H�@��s�JX��
|