pathlint 0.0.22

Lint the PATH environment variable against declarative ordering rules.
Documentation
//! Environment-variable expansion and slash normalization.
//!
//! Both `%VAR%` (Windows-style) and `$VAR` / `${VAR}` (POSIX-style) are
//! accepted on every OS so the same `pathlint.toml` works under
//! Windows pwsh, macOS bash, and Termux fish. Unresolved variables are
//! left verbatim — they simply fail to substring-match later.
//!
//! Slash normalization converts `\` to `/` so `mise\\shims` (TOML
//! literal) and `mise/shims` are equivalent for substring comparison.
//!
//! # Examples
//!
//! ```
//! use pathlint::expand;
//!
//! assert_eq!(expand::normalize("C:\\Users\\Me"), "c:/users/me");
//!
//! // Unresolved variables stay verbatim.
//! let raw = expand::expand_env("$THIS_VAR_DOES_NOT_EXIST_PROBABLY_XYZ/bin");
//! assert!(raw.contains("$THIS_VAR_DOES_NOT_EXIST_PROBABLY_XYZ") || raw.ends_with("/bin"));
//! ```

use std::env;

/// Expand `%VAR%`, `$VAR`, `${VAR}`, and a leading `~` against the
/// process environment. Unresolved variables are kept verbatim.
pub fn expand_env(input: &str) -> String {
    let tilde = expand_tilde(input);
    let dollar = expand_dollar(&tilde);
    expand_percent(&dollar)
}

/// Lowercase + slash-normalize. Use on both haystack and needle before
/// substring comparison.
pub fn normalize(input: &str) -> String {
    input.replace('\\', "/").to_ascii_lowercase()
}

/// Convenience: expand env vars then normalize.
pub fn expand_and_normalize(input: &str) -> String {
    normalize(&expand_env(input))
}

/// Read PATHEXT and split into the literal extension tokens
/// (`.EXE`, `.BAT`, …). Defaults to the standard Windows list when
/// PATHEXT is unset. Caller passes its own env lookup so tests
/// can stub it; pass `|v| std::env::var(v).ok()` for production.
/// Use `pathext_lower` instead when case-insensitive comparison
/// is needed (the doctor `duplicate_but_shadowed` detector).
pub(crate) fn pathext_raw(env_lookup: impl Fn(&str) -> Option<String>) -> Vec<String> {
    env_lookup("PATHEXT")
        .unwrap_or_else(|| ".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC".to_string())
        .split(';')
        .filter(|s| !s.is_empty())
        .map(|s| s.to_string())
        .collect()
}

/// PATHEXT entries lowercase. Convenience for case-insensitive
/// suffix matching on Windows.
pub(crate) fn pathext_lower(env_lookup: impl Fn(&str) -> Option<String>) -> Vec<String> {
    pathext_raw(env_lookup)
        .into_iter()
        .map(|s| s.to_ascii_lowercase())
        .collect()
}

fn expand_tilde(s: &str) -> String {
    if let Some(rest) = s.strip_prefix('~') {
        if let Some(home) = env::var_os("HOME") {
            return format!("{}{}", home.to_string_lossy(), rest);
        }
        if let Some(profile) = env::var_os("USERPROFILE") {
            return format!("{}{}", profile.to_string_lossy(), rest);
        }
    }
    s.to_string()
}

fn expand_dollar(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let bytes = s.as_bytes();
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == b'$' && i + 1 < bytes.len() {
            if bytes[i + 1] == b'{' {
                if let Some(end) = s[i + 2..].find('}') {
                    let name = &s[i + 2..i + 2 + end];
                    match env::var(name) {
                        Ok(val) => out.push_str(&val),
                        Err(_) => out.push_str(&s[i..i + 2 + end + 1]),
                    }
                    i += 2 + end + 1;
                    continue;
                }
            } else if is_ident_start(bytes[i + 1]) {
                let mut j = i + 1;
                while j < bytes.len() && is_ident_cont(bytes[j]) {
                    j += 1;
                }
                let name = &s[i + 1..j];
                match env::var(name) {
                    Ok(val) => out.push_str(&val),
                    Err(_) => out.push_str(&s[i..j]),
                }
                i = j;
                continue;
            }
        }
        out.push(bytes[i] as char);
        i += 1;
    }
    out
}

fn expand_percent(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let bytes = s.as_bytes();
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == b'%' {
            if let Some(rel_end) = s[i + 1..].find('%') {
                let name = &s[i + 1..i + 1 + rel_end];
                if !name.is_empty() && name.chars().all(is_ident_char) {
                    match env::var(name) {
                        Ok(val) => out.push_str(&val),
                        Err(_) => out.push_str(&s[i..i + 1 + rel_end + 1]),
                    }
                    i += 1 + rel_end + 1;
                    continue;
                }
            }
        }
        out.push(bytes[i] as char);
        i += 1;
    }
    out
}

fn is_ident_start(b: u8) -> bool {
    b.is_ascii_alphabetic() || b == b'_'
}

fn is_ident_cont(b: u8) -> bool {
    b.is_ascii_alphanumeric() || b == b'_'
}

fn is_ident_char(c: char) -> bool {
    c.is_ascii_alphanumeric() || c == '_'
}

#[cfg(test)]
mod tests {
    use super::*;

    fn with_var<F: FnOnce()>(key: &str, value: &str, body: F) {
        // Tests that touch process env vars must not run in parallel.
        // cargo test by default parallelizes; callers should use a unique
        // var name per test to avoid cross-test interference.
        // SAFETY: single-threaded scope per unique variable name.
        unsafe { env::set_var(key, value) };
        body();
        unsafe { env::remove_var(key) };
    }

    #[test]
    fn dollar_brace_expansion() {
        with_var("PATHLINT_TEST_BRACE", "ok", || {
            assert_eq!(expand_env("a/${PATHLINT_TEST_BRACE}/b"), "a/ok/b");
        });
    }

    #[test]
    fn dollar_bare_expansion() {
        with_var("PATHLINT_TEST_BARE", "ok", || {
            assert_eq!(expand_env("a/$PATHLINT_TEST_BARE/b"), "a/ok/b");
        });
    }

    #[test]
    fn percent_expansion() {
        with_var("PATHLINT_TEST_PCT", "ok", || {
            assert_eq!(expand_env("a/%PATHLINT_TEST_PCT%/b"), "a/ok/b");
        });
    }

    #[test]
    fn missing_var_is_kept_verbatim() {
        // Use a name that is exceedingly unlikely to be set.
        let s = "x/$PATHLINT_NOT_DEFINED_XYZ/y";
        assert_eq!(expand_env(s), s);
        let s2 = "x/%PATHLINT_NOT_DEFINED_XYZ%/y";
        assert_eq!(expand_env(s2), s2);
    }

    #[test]
    fn normalize_lowers_and_unifies_slashes() {
        assert_eq!(normalize("Foo\\Bar/Baz"), "foo/bar/baz");
    }

    #[test]
    fn lone_dollar_is_literal() {
        assert_eq!(expand_env("a$/b"), "a$/b");
    }

    #[test]
    fn mixed_percent_and_dollar_in_one_string() {
        with_var("PATHLINT_TEST_MIX_A", "AA", || {
            with_var("PATHLINT_TEST_MIX_B", "BB", || {
                let s = "%PATHLINT_TEST_MIX_A%/$PATHLINT_TEST_MIX_B/x";
                assert_eq!(expand_env(s), "AA/BB/x");
            });
        });
    }

    #[test]
    fn empty_input_is_empty() {
        assert_eq!(expand_env(""), "");
        assert_eq!(normalize(""), "");
    }

    #[test]
    fn unclosed_brace_is_kept_verbatim() {
        // `${FOO` (no closing brace) must not crash and must be left
        // alone.
        let s = "abc/${FOO/def";
        assert_eq!(expand_env(s), s);
    }

    #[test]
    fn percent_with_non_ident_inside_is_left_alone() {
        // `%not an ident%` is not a valid env-var name, must stay literal.
        let s = "50% off";
        assert_eq!(expand_env(s), s);
    }

    #[test]
    fn expand_and_normalize_combines_both_steps() {
        with_var("PATHLINT_TEST_COMBO", "C:/Users/U", || {
            assert_eq!(
                expand_and_normalize("$PATHLINT_TEST_COMBO\\.cargo\\bin"),
                "c:/users/u/.cargo/bin",
            );
        });
    }

    #[test]
    fn pathext_raw_uses_user_value_when_set() {
        let raw = pathext_raw(|v| {
            if v == "PATHEXT" {
                Some(".EXE;.BAT".to_string())
            } else {
                None
            }
        });
        assert_eq!(raw, vec![".EXE".to_string(), ".BAT".to_string()]);
    }

    #[test]
    fn pathext_raw_falls_back_to_default_when_unset() {
        let raw = pathext_raw(|_| None);
        assert!(raw.contains(&".EXE".to_string()));
        assert!(raw.contains(&".CMD".to_string()));
    }

    #[test]
    fn pathext_lower_normalizes_case() {
        let lower = pathext_lower(|v| {
            if v == "PATHEXT" {
                Some(".EXE;.Bat".to_string())
            } else {
                None
            }
        });
        assert_eq!(lower, vec![".exe".to_string(), ".bat".to_string()]);
    }
}