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    Ok(ExprValue::String(path_str.replace('\\', "/")))
44}
45
46pub fn with_name_fn(ctx: Ctx, a: &[ExprValue]) -> R {
47    let (path_str, fmt) = get_path(&a[0], ctx)?;
48    let new_name = get_str_arg(a, 1);
49    if new_name.contains('/') || (fmt == PathFormat::Windows && new_name.contains('\\')) {
50        return Err(ExpressionError::new(format!(
51            "with_name: name must not contain path separators, got '{new_name}'"
52        )));
53    }
54    let parent = pp::parent(&path_str, fmt);
55    let sep = pp::sep(fmt);
56    Ok(ExprValue::new_path(format!("{parent}{sep}{new_name}"), fmt))
57}
58
59pub fn with_stem_fn(ctx: Ctx, a: &[ExprValue]) -> R {
60    let (path_str, fmt) = get_path(&a[0], ctx)?;
61    let new_stem = get_str_arg(a, 1);
62    if new_stem.contains('/') || (fmt == PathFormat::Windows && new_stem.contains('\\')) {
63        return Err(ExpressionError::new(format!(
64            "with_stem: name must not contain path separators, got '{new_stem}'"
65        )));
66    }
67    let ext = pp::extension(&path_str, fmt);
68    let parent = pp::parent(&path_str, fmt);
69    let sep = pp::sep(fmt);
70    Ok(ExprValue::new_path(
71        format!("{parent}{sep}{new_stem}{ext}"),
72        fmt,
73    ))
74}
75
76pub fn with_suffix_fn(ctx: Ctx, a: &[ExprValue]) -> R {
77    let (path_str, fmt) = get_path(&a[0], ctx)?;
78    let new_suffix = get_str_arg(a, 1);
79    ctx.count_string_ops(path_str.len())?;
80    if crate::uri_path::is_uri(&path_str) {
81        let stem = crate::uri_path::stem(&path_str);
82        let parent = crate::uri_path::parent(&path_str);
83        return Ok(ExprValue::new_path(
84            format!("{parent}/{stem}{new_suffix}"),
85            fmt,
86        ));
87    }
88    let stem = pp::file_stem(&path_str, fmt);
89    let parent = pp::parent(&path_str, fmt);
90    let sep = pp::sep(fmt);
91    Ok(ExprValue::new_path(
92        format!("{parent}{sep}{stem}{new_suffix}"),
93        fmt,
94    ))
95}
96
97pub fn with_number_fn(ctx: Ctx, a: &[ExprValue]) -> R {
98    let (path_str, fmt) = get_path(&a[0], ctx)?;
99    let num = match &a[1] {
100        ExprValue::Int(n) => *n,
101        _ => return Err(ExpressionError::new("with_number() requires int argument")),
102    };
103    let is_string = matches!(&a[0], ExprValue::String(_));
104    let (dir_part, filename) = pp::split(&path_str, fmt);
105    let prefix = if dir_part.is_empty() {
106        String::new()
107    } else {
108        format!("{}{}", dir_part, pp::sep(fmt))
109    };
110    let (stem, suffix) = match filename.rfind('.') {
111        Some(i) if i > 0 => (&filename[..i], &filename[i..]),
112        _ => (filename, ""),
113    };
114    let new_stem = with_number_replace(stem, num)?;
115    let result = format!("{prefix}{new_stem}{suffix}");
116    if is_string {
117        Ok(ExprValue::String(result))
118    } else {
119        Ok(ExprValue::new_path(result, fmt))
120    }
121}
122
123pub fn is_absolute_fn(ctx: Ctx, a: &[ExprValue]) -> R {
124    let (path_str, fmt) = get_path(&a[0], ctx)?;
125    Ok(ExprValue::Bool(is_absolute(&path_str, fmt)))
126}
127
128/// Cross-platform is_absolute that respects path_format regardless of host OS.
129pub fn is_absolute(path_str: &str, fmt: PathFormat) -> bool {
130    if crate::uri_path::is_uri(path_str) {
131        return true;
132    }
133    let bytes = path_str.as_bytes();
134    // UNC path: //server or \\server
135    if bytes.len() >= 2
136        && ((bytes[0] == b'/' && bytes[1] == b'/') || (bytes[0] == b'\\' && bytes[1] == b'\\'))
137    {
138        return true;
139    }
140    match fmt {
141        PathFormat::Windows => {
142            bytes.len() >= 3
143                && bytes[0].is_ascii_alphabetic()
144                && bytes[1] == b':'
145                && (bytes[2] == b'\\' || bytes[2] == b'/')
146        }
147        PathFormat::Posix | PathFormat::Uri => bytes.first() == Some(&b'/'),
148    }
149}
150
151/// Join two path strings using the separator and absoluteness rules for `fmt`.
152///
153/// If `right` is absolute (according to `fmt`), it replaces `left` entirely.
154/// On Windows, if `right` starts with a single `/` or `\` (root-relative),
155/// the drive letter from `left` is preserved (matching `ntpath.join` behavior).
156/// Otherwise, `right` is appended to `left` with the appropriate separator.
157pub fn join(left: &str, right: &str, fmt: PathFormat) -> String {
158    if is_absolute(right, fmt) {
159        return right.to_string();
160    }
161    // Windows root-relative: /foo or \foo (but not \\server) replaces the path
162    // but keeps the root from left. Matches ntpath.join behavior.
163    // For drive paths (C:\...), the root is "C:".
164    // For UNC paths (\\server\share\...), the root is "\\server\share".
165    if fmt == PathFormat::Windows {
166        let rb = right.as_bytes();
167        if rb.first() == Some(&b'/') || rb.first() == Some(&b'\\') {
168            let lb = left.as_bytes();
169            // Drive path: keep "C:" prefix
170            if lb.len() >= 2 && lb[0].is_ascii_alphabetic() && lb[1] == b':' {
171                return format!("{}{right}", &left[..2]);
172            }
173            // UNC path: keep "\\server\share" or "//server/share" prefix
174            if let Some(unc_root) = extract_unc_root(left) {
175                return format!("{unc_root}{right}");
176            }
177        }
178    }
179    let left_is_uri = crate::uri_path::is_uri(left);
180    let (sep, trim_chars): (&str, &[char]) = if left_is_uri {
181        ("/", &['/'])
182    } else {
183        match fmt {
184            // On Windows, both / and \ are separators
185            PathFormat::Windows => ("\\", &['/', '\\']),
186            // On POSIX, only / is a separator (\ is a valid filename char)
187            PathFormat::Posix | PathFormat::Uri => ("/", &['/']),
188        }
189    };
190    let left = left.trim_end_matches(trim_chars);
191    // When appending to a URI from a Windows context, normalize backslashes to forward slashes.
192    // In POSIX context, backslashes are valid filename characters and must not be converted.
193    let right = if left_is_uri && fmt == PathFormat::Windows {
194        std::borrow::Cow::Owned(right.replace('\\', "/"))
195    } else {
196        std::borrow::Cow::Borrowed(right)
197    };
198    format!("{left}{sep}{right}")
199}
200
201/// Join two path strings without recognizing URIs as absolute.
202///
203/// Like [`join`], but does not check `is_absolute(right)`. Use when `right` has
204/// already been determined to be non-absolute via a URI-unaware check (e.g.,
205/// `is_absolute` without URI recognition). This prevents `scheme://...` strings
206/// from being treated as absolute when URI support is disabled.
207pub fn non_uri_join(left: &str, right: &str, fmt: PathFormat) -> String {
208    // Windows root-relative: /foo or \foo keeps the root from left
209    if fmt == PathFormat::Windows {
210        let rb = right.as_bytes();
211        if rb.first() == Some(&b'/') || rb.first() == Some(&b'\\') {
212            let lb = left.as_bytes();
213            if lb.len() >= 2 && lb[0].is_ascii_alphabetic() && lb[1] == b':' {
214                return format!("{}{right}", &left[..2]);
215            }
216            if let Some(unc_root) = extract_unc_root(left) {
217                return format!("{unc_root}{right}");
218            }
219        }
220    }
221    let (sep, trim_chars): (&str, &[char]) = match fmt {
222        PathFormat::Windows => ("\\", &['/', '\\']),
223        PathFormat::Posix | PathFormat::Uri => ("/", &['/']),
224    };
225    let left = left.trim_end_matches(trim_chars);
226    format!("{left}{sep}{right}")
227}
228
229/// Extract the UNC root from a path: `\\server\share` or `//server/share`.
230/// Returns the root portion (two components after the leading `\\` or `//`).
231fn extract_unc_root(path: &str) -> Option<&str> {
232    let bytes = path.as_bytes();
233    if bytes.len() < 2 {
234        return None;
235    }
236    let prefix_char = bytes[0];
237    if !((prefix_char == b'\\' && bytes[1] == b'\\') || (prefix_char == b'/' && bytes[1] == b'/')) {
238        return None;
239    }
240    // Find the separator after "server"
241    let rest = &path[2..];
242    let sep_after_server = rest.find(['/', '\\'])?;
243    let after_server = sep_after_server + 3; // 2 for prefix + 1 for separator
244                                             // Find the separator after "share" (or end of string)
245    let share_start = after_server;
246    let sep_after_share = path[share_start..]
247        .find(['/', '\\'])
248        .map(|i| share_start + i)
249        .unwrap_or(path.len());
250    Some(&path[..sep_after_share])
251}
252
253fn path_starts_with(path: &str, base: &str, fmt: PathFormat) -> bool {
254    if fmt == PathFormat::Windows {
255        path.len() >= base.len() && path[..base.len()].eq_ignore_ascii_case(base)
256    } else {
257        path.starts_with(base)
258    }
259}
260
261pub fn is_relative_to_fn(ctx: Ctx, a: &[ExprValue]) -> R {
262    let (path_str, fmt) = get_path(&a[0], ctx)?;
263    let base = get_str_arg(a, 1);
264    let is_rel = path_starts_with(&path_str, &base, fmt)
265        && (path_str.len() == base.len()
266            || base.ends_with('/')
267            || base.ends_with('\\')
268            || matches!(path_str.as_bytes().get(base.len()), Some(b'/' | b'\\')));
269    Ok(ExprValue::Bool(is_rel))
270}
271
272pub fn relative_to_fn(ctx: Ctx, a: &[ExprValue]) -> R {
273    let (path_str, fmt) = get_path(&a[0], ctx)?;
274    let base = get_str_arg(a, 1);
275    let is_rel = path_starts_with(&path_str, &base, fmt)
276        && (path_str.len() == base.len()
277            || base.ends_with('/')
278            || base.ends_with('\\')
279            || matches!(path_str.as_bytes().get(base.len()), Some(b'/' | b'\\')));
280    if !is_rel {
281        return Err(ExpressionError::new(format!(
282            "relative_to failed: '{path_str}' is not relative to '{base}'"
283        )));
284    }
285    let rel = path_str[base.len()..]
286        .trim_start_matches('/')
287        .trim_start_matches('\\');
288    Ok(ExprValue::new_path(
289        if rel.is_empty() {
290            ".".to_string()
291        } else {
292            rel.to_string()
293        },
294        fmt,
295    ))
296}
297
298/// Build a closure for `apply_path_mapping` that captures the given rules.
299///
300/// The rules are stored in an `Arc` so many `FunctionLibrary` clones can
301/// share them cheaply. This factory is the only way to produce an
302/// `apply_path_mapping` implementation; the host crate passes its rules
303/// at library-construction time rather than plumbing them through the
304/// evaluator on every call.
305pub fn make_apply_path_mapping_fn(
306    rules: std::sync::Arc<Vec<crate::path_mapping::PathMappingRule>>,
307) -> impl Fn(&mut dyn EvalContext, &[ExprValue]) -> R + Send + Sync + 'static {
308    move |ctx, a| {
309        let (path_str, fmt) = get_path(&a[0], ctx)?;
310        let mapped =
311            crate::path_mapping::apply_rules_with_format(&rules, &path_str, ctx.path_format());
312        if mapped == path_str {
313            Ok(ExprValue::new_path(path_str, fmt))
314        } else {
315            Ok(ExprValue::new_path(mapped, fmt))
316        }
317    }
318}
319
320fn format_padded(num: i64, width: usize) -> String {
321    if num < 0 {
322        format!("-{:0>width$}", -num, width = width.saturating_sub(1))
323    } else {
324        format!("{:0>width$}", num, width = width)
325    }
326}
327
328const MAX_PADDING_WIDTH: usize = 32;
329
330fn with_number_replace(stem: &str, num: i64) -> Result<String, ExpressionError> {
331    // 1. Printf %0Nd or %d
332    if let Some(pct) = stem.rfind('%') {
333        let after = &stem[pct + 1..];
334        if after == "d" {
335            return Ok(format!("{}{}", &stem[..pct], num));
336        }
337        if after.starts_with('0') && after.ends_with('d') {
338            let width: usize = after[1..after.len() - 1].parse().unwrap_or(1);
339            if width > MAX_PADDING_WIDTH {
340                return Err(ExpressionError::new(format!(
341                    "with_number: padding width {width} exceeds maximum of {MAX_PADDING_WIDTH}"
342                )));
343            }
344            return Ok(format!("{}{}", &stem[..pct], format_padded(num, width)));
345        }
346    }
347    // 2. Hash pattern ####
348    if let Some(start) = stem.rfind('#') {
349        let hash_start = stem[..=start]
350            .rfind(|c: char| c != '#')
351            .map(|i| i + 1)
352            .unwrap_or(0);
353        let width = start - hash_start + 1;
354        if width > MAX_PADDING_WIDTH {
355            return Err(ExpressionError::new(format!(
356                "with_number: padding width {width} exceeds maximum of {MAX_PADDING_WIDTH}"
357            )));
358        }
359        return Ok(format!(
360            "{}{}",
361            &stem[..hash_start],
362            format_padded(num, width)
363        ));
364    }
365    // 3. Trailing digits
366    let digit_start = stem.len()
367        - stem
368            .chars()
369            .rev()
370            .take_while(|c| c.is_ascii_digit())
371            .count();
372    if digit_start < stem.len() {
373        let width = stem.len() - digit_start;
374        return Ok(format!(
375            "{}{}",
376            &stem[..digit_start],
377            format_padded(num, width)
378        ));
379    }
380    // 4. No pattern — append _NNNN
381    Ok(format!("{}_{}", stem, format_padded(num, 4)))
382}
383
384// ── Path properties ──
385
386pub fn prop_name(ctx: Ctx, a: &[ExprValue]) -> R {
387    let (path_str, fmt) = get_path(&a[0], ctx)?;
388    ctx.count_string_ops(path_str.len())?;
389    if crate::uri_path::is_uri(&path_str) {
390        return Ok(ExprValue::String(crate::uri_path::name(&path_str)));
391    }
392    Ok(ExprValue::String(pp::file_name(&path_str, fmt).to_string()))
393}
394
395pub fn prop_stem(ctx: Ctx, a: &[ExprValue]) -> R {
396    let (path_str, fmt) = get_path(&a[0], ctx)?;
397    ctx.count_string_ops(path_str.len())?;
398    if crate::uri_path::is_uri(&path_str) {
399        return Ok(ExprValue::String(crate::uri_path::stem(&path_str)));
400    }
401    Ok(ExprValue::String(pp::file_stem(&path_str, fmt).to_string()))
402}
403
404pub fn prop_suffix(ctx: Ctx, a: &[ExprValue]) -> R {
405    let (path_str, fmt) = get_path(&a[0], ctx)?;
406    ctx.count_string_ops(path_str.len())?;
407    Ok(ExprValue::String(if crate::uri_path::is_uri(&path_str) {
408        crate::uri_path::suffix(&path_str)
409    } else {
410        pp::extension(&path_str, fmt).to_string()
411    }))
412}
413
414pub fn prop_suffixes(ctx: Ctx, a: &[ExprValue]) -> R {
415    let (path_str, fmt) = get_path(&a[0], ctx)?;
416    ctx.count_string_ops(path_str.len())?;
417    if crate::uri_path::is_uri(&path_str) {
418        let suffixes: Vec<ExprValue> = crate::uri_path::suffixes(&path_str)
419            .into_iter()
420            .map(ExprValue::String)
421            .collect();
422        return ExprValue::make_list_checked(ctx, suffixes, crate::types::ExprType::STRING);
423    }
424    let suffixes: Vec<ExprValue> = pp::suffixes(&path_str, fmt)
425        .into_iter()
426        .map(ExprValue::String)
427        .collect();
428    ExprValue::make_list_checked(ctx, suffixes, crate::types::ExprType::STRING)
429}
430
431pub fn prop_parent(ctx: Ctx, a: &[ExprValue]) -> R {
432    let (path_str, fmt) = get_path(&a[0], ctx)?;
433    ctx.count_string_ops(path_str.len())?;
434    if crate::uri_path::is_uri(&path_str) {
435        return Ok(ExprValue::new_path(crate::uri_path::parent(&path_str), fmt));
436    }
437    Ok(ExprValue::new_path(pp::parent(&path_str, fmt), fmt))
438}
439
440pub fn prop_parts(ctx: Ctx, a: &[ExprValue]) -> R {
441    let (path_str, fmt) = get_path(&a[0], ctx)?;
442    ctx.count_string_ops(path_str.len())?;
443    if crate::uri_path::is_uri(&path_str) {
444        let parts: Vec<ExprValue> = crate::uri_path::parts(&path_str)
445            .into_iter()
446            .map(ExprValue::String)
447            .collect();
448        return ExprValue::make_list_checked(ctx, parts, crate::types::ExprType::STRING);
449    }
450    let parts: Vec<ExprValue> = pp::parts(&path_str, fmt)
451        .into_iter()
452        .map(ExprValue::String)
453        .collect();
454    ExprValue::make_list_checked(ctx, parts, crate::types::ExprType::STRING)
455}