Skip to main content

openjd_expr/functions/
path.rs

1// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2// Copyright by contributors to this project.
3// SPDX-License-Identifier: (Apache-2.0 OR MIT)
4
5//! Path method implementations.
6//!
7//! Uses `path_parse` for format-aware path manipulation instead of
8//! `std::path::Path` which only understands the host OS's path format.
9
10use crate::error::ExpressionError;
11use crate::function_library::EvalContext;
12use crate::path_mapping::PathFormat;
13use crate::value::ExprValue;
14
15use super::path_parse as pp;
16
17type R = Result<ExprValue, ExpressionError>;
18type Ctx<'a> = &'a mut dyn EvalContext;
19
20fn get_path(a: &ExprValue, ctx: &dyn EvalContext) -> Result<(String, PathFormat), ExpressionError> {
21    match a {
22        ExprValue::Path { value, format } => Ok((value.clone(), *format)),
23        ExprValue::String(s) => Ok((s.clone(), ctx.path_format())),
24        _ => Err(ExpressionError::new(format!(
25            "Path method not supported on {}",
26            a.expr_type()
27        ))),
28    }
29}
30
31fn get_str_arg(a: &[ExprValue], idx: usize) -> String {
32    a.get(idx)
33        .map(|v| match v {
34            ExprValue::String(s) => s.clone(),
35            ExprValue::Path { value, .. } => value.clone(),
36            _ => String::new(),
37        })
38        .unwrap_or_default()
39}
40
41pub fn as_posix_fn(ctx: Ctx, a: &[ExprValue]) -> R {
42    let (path_str, _) = get_path(&a[0], ctx)?;
43    // `replace` walks the input and allocates a new string
44    // proportional to it. Budget the work so a sufficiently
45    // long input can't force unbounded compute or allocation.
46    ctx.count_string_ops(path_str.len())?;
47    Ok(ExprValue::String(path_str.replace('\\', "/")))
48}
49
50/// Return `true` if the input has no final component to operate
51/// on with `with_name` / `with_stem` / `with_suffix` / `with_number`.
52///
53/// For URI inputs, that's a bare authority (`s3://bucket`) or an
54/// authority with a trailing slash (`s3://bucket/`) — in both cases
55/// `uri_path::name` returns the empty string. For filesystem paths
56/// it's the same: a path whose `pp::file_name` is empty (the root
57/// `/` on POSIX, `C:\` or `\\server\share` on Windows). Without
58/// this guard, the `with_*` operators silently invent a filename
59/// against an empty stem and emit nonsensical results like
60/// `s3://bucket/.png` (a URI for a hidden file in the bucket root)
61/// or `//x` (a UNC-shaped path) — see `with_name_fn` etc. for the
62/// per-operator details.
63///
64/// Matches Python pathlib's behaviour:
65/// `PurePosixPath('/').with_name('x')` raises
66/// `ValueError: PurePosixPath('/') has an empty name`.
67fn has_empty_name(path_str: &str, fmt: PathFormat) -> bool {
68    if crate::uri_path::is_uri(path_str) {
69        crate::uri_path::name(path_str).is_empty()
70    } else {
71        pp::file_name(path_str, fmt).is_empty()
72    }
73}
74
75/// Return `true` if `name` is a valid replacement filename for
76/// `with_name` / `with_stem`.
77///
78/// Matches Python pathlib's `with_name` validation: rejects the
79/// empty string, the `.` sentinel, and any string containing a
80/// path separator. `..` is *accepted* — pathlib treats it as a
81/// valid name (it's a filename that happens to look like the
82/// parent-dir indicator). On Windows both `/` and `\` are
83/// separators; on POSIX only `/`.
84fn is_valid_name(name: &str, fmt: PathFormat) -> bool {
85    if name.is_empty() || name == "." {
86        return false;
87    }
88    if name.contains('/') {
89        return false;
90    }
91    if fmt == PathFormat::Windows && name.contains('\\') {
92        return false;
93    }
94    true
95}
96
97/// Return `true` if `suffix` is a valid replacement suffix for
98/// `with_suffix`.
99///
100/// Matches Python pathlib's `with_suffix` validation:
101/// - Empty string is OK (strips the suffix).
102/// - Otherwise the suffix MUST start with `.` AND not be just
103///   `.` (so `.x` is OK, `.` alone is rejected).
104/// - The suffix MUST NOT contain a separator (`/` always; `\\`
105///   on Windows).
106fn is_valid_suffix(suffix: &str, fmt: PathFormat) -> bool {
107    if suffix.is_empty() {
108        return true;
109    }
110    if !suffix.starts_with('.') || suffix == "." {
111        return false;
112    }
113    if suffix.contains('/') {
114        return false;
115    }
116    if fmt == PathFormat::Windows && suffix.contains('\\') {
117        return false;
118    }
119    true
120}
121
122/// Join `parent` and `name` with the format's separator, but
123/// elide the separator when `parent` is empty (relative
124/// single-component path) or already ends with a separator
125/// (filesystem anchor like `/`, `C:\`, or `\\server\share\`).
126/// Without this, a single-component relative path
127/// (`pp::parent("foo") == ""`) would produce `"/x"` instead of
128/// `"x"`, and a drive-rooted path (`pp::parent("C:\\foo") ==
129/// "C:\\"`) would produce `"C:\\\\x"` instead of `"C:\\x"`.
130///
131/// Matches Python pathlib: `PurePosixPath("foo").with_name("x")
132/// == PurePosixPath("x")`, `PurePosixPath("/foo").with_name("x")
133/// == PurePosixPath("/x")`.
134fn join_parent_and_name(parent: &str, name: &str, fmt: PathFormat) -> String {
135    if parent.is_empty() || parent == "." {
136        // Pathlib's `with_*` operators treat a relative
137        // single-component parent (rendered as `.`) as having
138        // no parent for join purposes:
139        //   PurePosixPath("foo").with_name("x") == "x", not "./x".
140        // The `parent` *property* still renders as `.` per
141        // pathlib (see `pp::parent`), but the join here drops
142        // the leading dot to match pathlib's `_from_parsed_parts`
143        // semantics.
144        //
145        // Pathlib prepends `.\` on Windows when the resulting
146        // name would otherwise parse as a drive-relative anchor
147        // (`X:y`, `X:`). The pattern: at least 2 chars, first
148        // char ASCII alphabetic, second char `:`. Without this
149        // adjustment, `with_name('x:y')` on a relative path
150        // would produce `'x:y'`, which when re-parsed as a
151        // Windows path looks like the drive-relative path
152        // `x:` + `y`. Pathlib disambiguates by emitting
153        // `.\x:y`. POSIX has no such ambiguity.
154        if fmt == PathFormat::Windows {
155            let nb = name.as_bytes();
156            if nb.len() >= 2 && nb[0].is_ascii_alphabetic() && nb[1] == b':' {
157                return format!(".\\{name}");
158            }
159        }
160        return name.to_string();
161    }
162    let last = parent.as_bytes().last().copied();
163    let already_terminated = match fmt {
164        PathFormat::Windows => {
165            // Trailing separator (`C:\`, `\\srv\share\`, `\foo\`,
166            // etc.) — no extra separator needed.
167            if last == Some(b'/') || last == Some(b'\\') {
168                true
169            } else {
170                // Drive-relative anchor (`C:`, `D:`) with no
171                // following separator. Pathlib joins this without
172                // a separator: `PureWindowsPath('C:foo').with_name('x')`
173                // produces `'C:x'`, not `'C:\\x'`. Detect by:
174                // length == 2, byte 0 alphabetic, byte 1 == ':'.
175                let bytes = parent.as_bytes();
176                bytes.len() == 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
177            }
178        }
179        PathFormat::Posix | PathFormat::Uri => last == Some(b'/'),
180    };
181    if already_terminated {
182        format!("{parent}{name}")
183    } else {
184        format!("{parent}{sep}{name}", sep = pp::sep(fmt))
185    }
186}
187
188pub fn with_name_fn(ctx: Ctx, a: &[ExprValue]) -> R {
189    let (path_str, fmt) = get_path(&a[0], ctx)?;
190    let new_name = get_str_arg(a, 1);
191    if !is_valid_name(&new_name, fmt) {
192        return Err(ExpressionError::new(format!(
193            "with_name: Invalid name '{new_name}'"
194        )));
195    }
196    // Charge an operation budget proportional to the input
197    // length so an attacker cannot construct an input that
198    // forces unbounded string work. Matches the convention used
199    // by `with_suffix_fn` and the path-property helpers
200    // (`prop_name`, `prop_stem`, etc.) — see
201    // `EvalContext::count_string_ops`.
202    ctx.count_string_ops(path_str.len())?;
203    if has_empty_name(&path_str, fmt) {
204        return Err(ExpressionError::new(format!(
205            "with_name: '{path_str}' has an empty name"
206        )));
207    }
208    // URI paths have their own segment grammar that's independent
209    // of the host's path format. Without this branch, on a Windows
210    // host `with_name(...)` on a URI value would emit
211    // `s3:\\bucket\dir\file.ext` because `pp::parent` /
212    // `pp::sep` use the host separator. See `with_suffix_fn`,
213    // which already handled this; this branch is the same idea
214    // for `with_name`.
215    if crate::uri_path::is_uri(&path_str) {
216        let parent = crate::uri_path::parent(&path_str);
217        return Ok(ExprValue::new_path(format!("{parent}/{new_name}"), fmt));
218    }
219    let parent = pp::parent(&path_str, fmt);
220    Ok(ExprValue::new_path(
221        join_parent_and_name(&parent, &new_name, fmt),
222        fmt,
223    ))
224}
225
226pub fn with_stem_fn(ctx: Ctx, a: &[ExprValue]) -> R {
227    let (path_str, fmt) = get_path(&a[0], ctx)?;
228    let new_stem = get_str_arg(a, 1);
229    // pathlib's `with_stem` is implemented as
230    // `self.with_name(stem + self.suffix)`. We mirror that — the
231    // separator-containing and empty-stem-with-no-suffix cases
232    // turn into `Invalid name` errors after the filename is
233    // constructed, and the empty-stem-on-path-with-suffix case
234    // becomes `has a non-empty suffix`. Special-case the latter
235    // because pathlib uses a more specific diagnostic for it.
236    //
237    // Note: a stem of `.` is NOT rejected up front. When the
238    // path has a non-empty suffix, the resulting filename is
239    // `.{suffix}` (e.g., `..txt` for `'foo.txt'.with_stem('.')`),
240    // which is a valid name. When there's no suffix, the
241    // resulting filename is just `.` and the post-construction
242    // `is_valid_name` check catches it as `Invalid name '.'`.
243    // Only reject up front the cases pathlib's `with_name`
244    // rejects on the stem alone — separator-containing names.
245    if !new_stem.is_empty() && new_stem != "." {
246        // Cheap up-front separator check; the post-construction
247        // check still catches the stem-equals-dot case.
248        if new_stem.contains('/') || (fmt == PathFormat::Windows && new_stem.contains('\\')) {
249            return Err(ExpressionError::new(format!(
250                "with_stem: Invalid name '{new_stem}'"
251            )));
252        }
253    }
254    ctx.count_string_ops(path_str.len())?;
255    if has_empty_name(&path_str, fmt) {
256        return Err(ExpressionError::new(format!(
257            "with_stem: '{path_str}' has an empty name"
258        )));
259    }
260    // URI paths use `/` regardless of host. See `with_name_fn`
261    // for the rationale; this branch is the same idea for
262    // `with_stem`. Suffix is taken from the URI grammar
263    // (`uri_path::suffix`), not from `pp::extension`, so the
264    // result is host-format-independent end-to-end.
265    let is_uri = crate::uri_path::is_uri(&path_str);
266    let (parent, ext) = if is_uri {
267        (
268            crate::uri_path::parent(&path_str),
269            crate::uri_path::suffix(&path_str),
270        )
271    } else {
272        (
273            pp::parent(&path_str, fmt),
274            pp::extension(&path_str, fmt).to_string(),
275        )
276    };
277    if new_stem.is_empty() && !ext.is_empty() {
278        // Specific pathlib diagnostic for this case.
279        return Err(ExpressionError::new(format!(
280            "with_stem: '{path_str}' has a non-empty suffix"
281        )));
282    }
283    let new_filename = format!("{new_stem}{ext}");
284    if !is_valid_name(&new_filename, fmt) {
285        return Err(ExpressionError::new(format!(
286            "with_stem: Invalid name '{new_filename}'"
287        )));
288    }
289    let result = if is_uri {
290        format!("{parent}/{new_filename}")
291    } else {
292        join_parent_and_name(&parent, &new_filename, fmt)
293    };
294    Ok(ExprValue::new_path(result, fmt))
295}
296
297pub fn with_suffix_fn(ctx: Ctx, a: &[ExprValue]) -> R {
298    let (path_str, fmt) = get_path(&a[0], ctx)?;
299    let new_suffix = get_str_arg(a, 1);
300    if !is_valid_suffix(&new_suffix, fmt) {
301        return Err(ExpressionError::new(format!(
302            "with_suffix: Invalid suffix '{new_suffix}'"
303        )));
304    }
305    ctx.count_string_ops(path_str.len())?;
306    if has_empty_name(&path_str, fmt) {
307        return Err(ExpressionError::new(format!(
308            "with_suffix: '{path_str}' has an empty name"
309        )));
310    }
311    let is_uri = crate::uri_path::is_uri(&path_str);
312    let (parent, stem) = if is_uri {
313        (
314            crate::uri_path::parent(&path_str),
315            crate::uri_path::stem(&path_str),
316        )
317    } else {
318        (
319            pp::parent(&path_str, fmt),
320            pp::file_stem(&path_str, fmt).to_string(),
321        )
322    };
323    let new_filename = format!("{stem}{new_suffix}");
324    // Pathlib also validates the constructed filename and raises
325    // if it's invalid. Concretely: `'..foo'.with_suffix('')`
326    // would produce the filename `.` (because `'..foo'.stem` is
327    // `.` per pathlib's rule), and pathlib catches that with
328    // `Invalid name '.'`. Re-running our `is_valid_name` against
329    // the constructed filename catches the same case.
330    if !is_valid_name(&new_filename, fmt) {
331        return Err(ExpressionError::new(format!(
332            "with_suffix: Invalid name '{new_filename}'"
333        )));
334    }
335    let result = if is_uri {
336        format!("{parent}/{new_filename}")
337    } else {
338        join_parent_and_name(&parent, &new_filename, fmt)
339    };
340    Ok(ExprValue::new_path(result, fmt))
341}
342
343/// Split a filename into `(stem, suffix)` per Python pathlib's
344/// rule: the suffix exists only when the rightmost `.` is neither
345/// at the start of the name nor at the end. Used by
346/// `with_number_fn` to preserve the suffix verbatim while
347/// substituting the stem.
348fn split_name_at_suffix(filename: &str) -> (&str, &str) {
349    match filename.rfind('.') {
350        Some(i) if i > 0 && i + 1 < filename.len() => (&filename[..i], &filename[i..]),
351        _ => (filename, ""),
352    }
353}
354
355pub fn with_number_fn(ctx: Ctx, a: &[ExprValue]) -> R {
356    let (path_str, fmt) = get_path(&a[0], ctx)?;
357    let num = match &a[1] {
358        ExprValue::Int(n) => *n,
359        _ => return Err(ExpressionError::new("with_number() requires int argument")),
360    };
361    ctx.count_string_ops(path_str.len())?;
362    if has_empty_name(&path_str, fmt) {
363        return Err(ExpressionError::new(format!(
364            "with_number: '{path_str}' has an empty name"
365        )));
366    }
367    let is_string = matches!(&a[0], ExprValue::String(_));
368    // URI paths use `/` regardless of host. Without this branch,
369    // on a Windows host `with_number(...)` on a URI value would
370    // emit `s3://bucket/renders\shot_0042.exr` because
371    // `pp::split` and `pp::sep` use the host separator. See
372    // `with_name_fn` / `with_stem_fn` for the analogous fix.
373    let result = if crate::uri_path::is_uri(&path_str) {
374        let parent = crate::uri_path::parent(&path_str);
375        let filename = crate::uri_path::name(&path_str);
376        let (stem, suffix) = split_name_at_suffix(&filename);
377        let new_stem = with_number_replace(stem, num)?;
378        // `parent` is the URI authority + leading path segments.
379        format!("{parent}/{new_stem}{suffix}")
380    } else {
381        let (dir_part, filename) = pp::split(&path_str, fmt);
382        let (stem, suffix) = split_name_at_suffix(filename);
383        let new_stem = with_number_replace(stem, num)?;
384        let new_filename = format!("{new_stem}{suffix}");
385        join_parent_and_name(dir_part, &new_filename, fmt)
386    };
387    if is_string {
388        Ok(ExprValue::String(result))
389    } else {
390        Ok(ExprValue::new_path(result, fmt))
391    }
392}
393
394pub fn is_absolute_fn(ctx: Ctx, a: &[ExprValue]) -> R {
395    let (path_str, fmt) = get_path(&a[0], ctx)?;
396    Ok(ExprValue::Bool(is_absolute(&path_str, fmt)))
397}
398
399/// Cross-platform is_absolute that respects path_format regardless of host OS.
400pub fn is_absolute(path_str: &str, fmt: PathFormat) -> bool {
401    if crate::uri_path::is_uri(path_str) {
402        return true;
403    }
404    let bytes = path_str.as_bytes();
405    // UNC path: //server or \\server
406    if bytes.len() >= 2
407        && ((bytes[0] == b'/' && bytes[1] == b'/') || (bytes[0] == b'\\' && bytes[1] == b'\\'))
408    {
409        return true;
410    }
411    match fmt {
412        PathFormat::Windows => {
413            bytes.len() >= 3
414                && bytes[0].is_ascii_alphabetic()
415                && bytes[1] == b':'
416                && (bytes[2] == b'\\' || bytes[2] == b'/')
417        }
418        PathFormat::Posix | PathFormat::Uri => bytes.first() == Some(&b'/'),
419    }
420}
421
422/// Join two path strings using the separator and absoluteness rules for `fmt`.
423///
424/// If `right` is absolute (according to `fmt`), it replaces `left` entirely.
425/// On Windows, if `right` starts with a single `/` or `\` (root-relative),
426/// the drive letter from `left` is preserved (matching `ntpath.join` behavior).
427/// Otherwise, `right` is appended to `left` with the appropriate separator.
428pub fn join(left: &str, right: &str, fmt: PathFormat) -> String {
429    if is_absolute(right, fmt) {
430        return right.to_string();
431    }
432    // Windows root-relative: /foo or \foo (but not \\server) replaces the path
433    // but keeps the root from left. Matches ntpath.join behavior.
434    // For drive paths (C:\...), the root is "C:".
435    // For UNC paths (\\server\share\...), the root is "\\server\share".
436    if fmt == PathFormat::Windows {
437        let rb = right.as_bytes();
438        if rb.first() == Some(&b'/') || rb.first() == Some(&b'\\') {
439            let lb = left.as_bytes();
440            // Drive path: keep "C:" prefix
441            if lb.len() >= 2 && lb[0].is_ascii_alphabetic() && lb[1] == b':' {
442                return format!("{}{right}", &left[..2]);
443            }
444            // UNC path: keep "\\server\share" or "//server/share" prefix
445            if let Some(unc_root) = extract_unc_root(left) {
446                return format!("{unc_root}{right}");
447            }
448        }
449    }
450    let left_is_uri = crate::uri_path::is_uri(left);
451    let (sep, trim_chars): (&str, &[char]) = if left_is_uri {
452        ("/", &['/'])
453    } else {
454        match fmt {
455            // On Windows, both / and \ are separators
456            PathFormat::Windows => ("\\", &['/', '\\']),
457            // On POSIX, only / is a separator (\ is a valid filename char)
458            PathFormat::Posix | PathFormat::Uri => ("/", &['/']),
459        }
460    };
461    let left = left.trim_end_matches(trim_chars);
462    // When appending to a URI from a Windows context, normalize backslashes to forward slashes.
463    // In POSIX context, backslashes are valid filename characters and must not be converted.
464    let right = if left_is_uri && fmt == PathFormat::Windows {
465        std::borrow::Cow::Owned(right.replace('\\', "/"))
466    } else {
467        std::borrow::Cow::Borrowed(right)
468    };
469    format!("{left}{sep}{right}")
470}
471
472/// Join two path strings without recognizing URIs as absolute.
473///
474/// Like [`join`], but does not check `is_absolute(right)`. Use when `right` has
475/// already been determined to be non-absolute via a URI-unaware check (e.g.,
476/// `is_absolute` without URI recognition). This prevents `scheme://...` strings
477/// from being treated as absolute when URI support is disabled.
478pub fn non_uri_join(left: &str, right: &str, fmt: PathFormat) -> String {
479    // Windows root-relative: /foo or \foo keeps the root from left
480    if fmt == PathFormat::Windows {
481        let rb = right.as_bytes();
482        if rb.first() == Some(&b'/') || rb.first() == Some(&b'\\') {
483            let lb = left.as_bytes();
484            if lb.len() >= 2 && lb[0].is_ascii_alphabetic() && lb[1] == b':' {
485                return format!("{}{right}", &left[..2]);
486            }
487            if let Some(unc_root) = extract_unc_root(left) {
488                return format!("{unc_root}{right}");
489            }
490        }
491    }
492    let (sep, trim_chars): (&str, &[char]) = match fmt {
493        PathFormat::Windows => ("\\", &['/', '\\']),
494        PathFormat::Posix | PathFormat::Uri => ("/", &['/']),
495    };
496    let left = left.trim_end_matches(trim_chars);
497    format!("{left}{sep}{right}")
498}
499
500/// Extract the UNC root from a path: `\\server\share` or `//server/share`.
501/// Returns the root portion (two components after the leading `\\` or `//`).
502fn extract_unc_root(path: &str) -> Option<&str> {
503    let bytes = path.as_bytes();
504    if bytes.len() < 2 {
505        return None;
506    }
507    let prefix_char = bytes[0];
508    if !((prefix_char == b'\\' && bytes[1] == b'\\') || (prefix_char == b'/' && bytes[1] == b'/')) {
509        return None;
510    }
511    // Find the separator after "server"
512    let rest = &path[2..];
513    let sep_after_server = rest.find(['/', '\\'])?;
514    let after_server = sep_after_server + 3; // 2 for prefix + 1 for separator
515                                             // Find the separator after "share" (or end of string)
516    let share_start = after_server;
517    let sep_after_share = path[share_start..]
518        .find(['/', '\\'])
519        .map(|i| share_start + i)
520        .unwrap_or(path.len());
521    Some(&path[..sep_after_share])
522}
523
524fn path_starts_with(path: &str, base: &str, fmt: PathFormat) -> bool {
525    if fmt == PathFormat::Windows {
526        path.len() >= base.len() && path[..base.len()].eq_ignore_ascii_case(base)
527    } else {
528        path.starts_with(base)
529    }
530}
531
532pub fn is_relative_to_fn(ctx: Ctx, a: &[ExprValue]) -> R {
533    let (path_str, fmt) = get_path(&a[0], ctx)?;
534    let base = get_str_arg(a, 1);
535    // The prefix comparison below is `O(min(path_str.len(),
536    // base.len()))` in the worst case (especially on Windows where
537    // it does a case-insensitive compare). Budget the work
538    // proportionally to the larger input so a long pair can't
539    // force unbounded compute.
540    ctx.count_string_ops(path_str.len().max(base.len()))?;
541    let is_rel = path_starts_with(&path_str, &base, fmt)
542        && (path_str.len() == base.len()
543            || base.ends_with('/')
544            || base.ends_with('\\')
545            || matches!(path_str.as_bytes().get(base.len()), Some(b'/' | b'\\')));
546    Ok(ExprValue::Bool(is_rel))
547}
548
549pub fn relative_to_fn(ctx: Ctx, a: &[ExprValue]) -> R {
550    let (path_str, fmt) = get_path(&a[0], ctx)?;
551    let base = get_str_arg(a, 1);
552    // Same rationale as `is_relative_to_fn`. This function also
553    // clones the tail of `path_str` into a fresh String, so the
554    // allocation is `O(path_str.len() - base.len())`, but the
555    // input-length bound here is the safer upper bound.
556    ctx.count_string_ops(path_str.len().max(base.len()))?;
557    let is_rel = path_starts_with(&path_str, &base, fmt)
558        && (path_str.len() == base.len()
559            || base.ends_with('/')
560            || base.ends_with('\\')
561            || matches!(path_str.as_bytes().get(base.len()), Some(b'/' | b'\\')));
562    if !is_rel {
563        return Err(ExpressionError::new(format!(
564            "relative_to failed: '{path_str}' is not relative to '{base}'"
565        )));
566    }
567    let rel = path_str[base.len()..]
568        .trim_start_matches('/')
569        .trim_start_matches('\\');
570    Ok(ExprValue::new_path(
571        if rel.is_empty() {
572            ".".to_string()
573        } else {
574            rel.to_string()
575        },
576        fmt,
577    ))
578}
579
580/// Build a closure for `apply_path_mapping` that captures the given rules.
581///
582/// The rules are stored in an `Arc` so many `FunctionLibrary` clones can
583/// share them cheaply. This factory is the only way to produce an
584/// `apply_path_mapping` implementation; the host crate passes its rules
585/// at library-construction time rather than plumbing them through the
586/// evaluator on every call.
587pub fn make_apply_path_mapping_fn(
588    rules: std::sync::Arc<Vec<crate::path_mapping::PathMappingRule>>,
589) -> impl Fn(&mut dyn EvalContext, &[ExprValue]) -> R + Send + Sync + 'static {
590    move |ctx, a| {
591        let (path_str, fmt) = get_path(&a[0], ctx)?;
592        // `apply_rules_with_format` walks the rules and does an
593        // `O(rules × path_len)` prefix comparison + a string
594        // substitution. Budget the work proportionally to the
595        // input length — the rule count is bounded by the
596        // session profile, but the path length is caller-
597        // controlled.
598        ctx.count_string_ops(path_str.len())?;
599        let mapped =
600            crate::path_mapping::apply_rules_with_format(&rules, &path_str, ctx.path_format());
601        if mapped == path_str {
602            Ok(ExprValue::new_path(path_str, fmt))
603        } else {
604            Ok(ExprValue::new_path(mapped, fmt))
605        }
606    }
607}
608
609fn format_padded(num: i64, width: usize) -> String {
610    if num < 0 {
611        format!("-{:0>width$}", -num, width = width.saturating_sub(1))
612    } else {
613        format!("{:0>width$}", num, width = width)
614    }
615}
616
617const MAX_PADDING_WIDTH: usize = 32;
618
619fn with_number_replace(stem: &str, num: i64) -> Result<String, ExpressionError> {
620    // 1. Printf %0Nd or %d
621    if let Some(pct) = stem.rfind('%') {
622        let after = &stem[pct + 1..];
623        if after == "d" {
624            return Ok(format!("{}{}", &stem[..pct], num));
625        }
626        if after.starts_with('0') && after.ends_with('d') {
627            let width: usize = after[1..after.len() - 1].parse().unwrap_or(1);
628            if width > MAX_PADDING_WIDTH {
629                return Err(ExpressionError::new(format!(
630                    "with_number: padding width {width} exceeds maximum of {MAX_PADDING_WIDTH}"
631                )));
632            }
633            return Ok(format!("{}{}", &stem[..pct], format_padded(num, width)));
634        }
635    }
636    // 2. Hash pattern ####
637    if let Some(start) = stem.rfind('#') {
638        let hash_start = stem[..=start]
639            .rfind(|c: char| c != '#')
640            .map(|i| i + 1)
641            .unwrap_or(0);
642        let width = start - hash_start + 1;
643        if width > MAX_PADDING_WIDTH {
644            return Err(ExpressionError::new(format!(
645                "with_number: padding width {width} exceeds maximum of {MAX_PADDING_WIDTH}"
646            )));
647        }
648        return Ok(format!(
649            "{}{}",
650            &stem[..hash_start],
651            format_padded(num, width)
652        ));
653    }
654    // 3. Trailing digits
655    let digit_start = stem.len()
656        - stem
657            .chars()
658            .rev()
659            .take_while(|c| c.is_ascii_digit())
660            .count();
661    if digit_start < stem.len() {
662        let width = stem.len() - digit_start;
663        return Ok(format!(
664            "{}{}",
665            &stem[..digit_start],
666            format_padded(num, width)
667        ));
668    }
669    // 4. No pattern — append _NNNN
670    Ok(format!("{}_{}", stem, format_padded(num, 4)))
671}
672
673// ── Path properties ──
674
675pub fn prop_name(ctx: Ctx, a: &[ExprValue]) -> R {
676    let (path_str, fmt) = get_path(&a[0], ctx)?;
677    ctx.count_string_ops(path_str.len())?;
678    if crate::uri_path::is_uri(&path_str) {
679        return Ok(ExprValue::String(crate::uri_path::name(&path_str)));
680    }
681    Ok(ExprValue::String(pp::file_name(&path_str, fmt).to_string()))
682}
683
684pub fn prop_stem(ctx: Ctx, a: &[ExprValue]) -> R {
685    let (path_str, fmt) = get_path(&a[0], ctx)?;
686    ctx.count_string_ops(path_str.len())?;
687    if crate::uri_path::is_uri(&path_str) {
688        return Ok(ExprValue::String(crate::uri_path::stem(&path_str)));
689    }
690    Ok(ExprValue::String(pp::file_stem(&path_str, fmt).to_string()))
691}
692
693pub fn prop_suffix(ctx: Ctx, a: &[ExprValue]) -> R {
694    let (path_str, fmt) = get_path(&a[0], ctx)?;
695    ctx.count_string_ops(path_str.len())?;
696    Ok(ExprValue::String(if crate::uri_path::is_uri(&path_str) {
697        crate::uri_path::suffix(&path_str)
698    } else {
699        pp::extension(&path_str, fmt).to_string()
700    }))
701}
702
703pub fn prop_suffixes(ctx: Ctx, a: &[ExprValue]) -> R {
704    let (path_str, fmt) = get_path(&a[0], ctx)?;
705    ctx.count_string_ops(path_str.len())?;
706    if crate::uri_path::is_uri(&path_str) {
707        let suffixes: Vec<ExprValue> = crate::uri_path::suffixes(&path_str)
708            .into_iter()
709            .map(ExprValue::String)
710            .collect();
711        return ExprValue::make_list_checked(ctx, suffixes, crate::types::ExprType::STRING);
712    }
713    let suffixes: Vec<ExprValue> = pp::suffixes(&path_str, fmt)
714        .into_iter()
715        .map(ExprValue::String)
716        .collect();
717    ExprValue::make_list_checked(ctx, suffixes, crate::types::ExprType::STRING)
718}
719
720pub fn prop_parent(ctx: Ctx, a: &[ExprValue]) -> R {
721    let (path_str, fmt) = get_path(&a[0], ctx)?;
722    ctx.count_string_ops(path_str.len())?;
723    if crate::uri_path::is_uri(&path_str) {
724        return Ok(ExprValue::new_path(crate::uri_path::parent(&path_str), fmt));
725    }
726    Ok(ExprValue::new_path(pp::parent(&path_str, fmt), fmt))
727}
728
729pub fn prop_parts(ctx: Ctx, a: &[ExprValue]) -> R {
730    let (path_str, fmt) = get_path(&a[0], ctx)?;
731    ctx.count_string_ops(path_str.len())?;
732    if crate::uri_path::is_uri(&path_str) {
733        let parts: Vec<ExprValue> = crate::uri_path::parts(&path_str)
734            .into_iter()
735            .map(ExprValue::String)
736            .collect();
737        return ExprValue::make_list_checked(ctx, parts, crate::types::ExprType::STRING);
738    }
739    let parts: Vec<ExprValue> = pp::parts(&path_str, fmt)
740        .into_iter()
741        .map(ExprValue::String)
742        .collect();
743    ExprValue::make_list_checked(ctx, parts, crate::types::ExprType::STRING)
744}