hni 0.0.3

ni-compatible package manager command router with node shim
Documentation
use std::path::{Path, PathBuf};

use super::{is_node_program, looks_like_env_assignment};

#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct NodeBinLaunch {
    pub script_path: PathBuf,
    pub node_args: Vec<String>,
}

pub(super) fn node_args_from_shebang(line: &str) -> Option<Vec<String>> {
    let shebang = line.strip_prefix("#!")?.trim();
    let mut tokens = shlex::split(shebang)?;
    if tokens.is_empty() {
        return None;
    }

    if is_env_program(&tokens[0]) {
        tokens.remove(0);
        if tokens.first().is_some_and(|token| token == "-S") {
            tokens.remove(0);
        }
        while tokens
            .first()
            .is_some_and(|token| looks_like_env_assignment(token))
        {
            tokens.remove(0);
        }
    }

    let program = tokens.first()?;
    if !is_node_program(program) {
        return None;
    }

    Some(tokens.into_iter().skip(1).collect())
}

pub(super) fn parse_node_shell_shim(raw: &str, shim_path: &Path) -> Option<NodeBinLaunch> {
    let shim_dir = shim_path.parent()?;

    for line in raw.lines() {
        let trimmed = line.trim();
        if !trimmed.starts_with("exec ") {
            continue;
        }

        let Some(tokens) = shlex::split(trimmed) else {
            continue;
        };

        if tokens.len() < 4 || tokens.first().map(String::as_str) != Some("exec") {
            continue;
        }

        let Some(program) = tokens.get(1) else {
            continue;
        };
        if !(is_node_program(program) || is_basedir_node_program(program)) {
            continue;
        }

        if tokens.last().map(String::as_str) != Some("$@") {
            continue;
        }

        let Some(script_token) = tokens.get(tokens.len() - 2) else {
            continue;
        };
        let Some(script_path) = resolve_shim_path_token(script_token, shim_dir) else {
            continue;
        };
        if !looks_like_node_script_path(&script_path) {
            continue;
        }

        return Some(NodeBinLaunch {
            script_path,
            node_args: tokens[2..tokens.len() - 2].to_vec(),
        });
    }

    None
}

fn resolve_shim_path_token(token: &str, shim_dir: &Path) -> Option<PathBuf> {
    if let Some(relative) = token.strip_prefix("$basedir/") {
        return Some(shim_dir.join(relative));
    }

    if let Some(relative) = token.strip_prefix("$basedir\\") {
        return Some(shim_dir.join(relative.replace('\\', "/")));
    }

    if !token.contains('/') && !token.contains('\\') {
        return None;
    }

    let path = Path::new(token);
    Some(if path.is_absolute() {
        path.to_path_buf()
    } else {
        shim_dir.join(path)
    })
}

fn looks_like_node_script_path(path: &Path) -> bool {
    matches!(
        path.extension()
            .and_then(|value| value.to_str())
            .map(|value| value.to_ascii_lowercase())
            .as_deref(),
        Some("js") | Some("cjs") | Some("mjs")
    )
}

fn is_env_program(program: &str) -> bool {
    Path::new(program)
        .file_name()
        .and_then(|value| value.to_str())
        .is_some_and(|value| value.eq_ignore_ascii_case("env"))
}

fn is_basedir_node_program(program: &str) -> bool {
    matches!(program, "$basedir/node" | "$basedir/node.exe")
}

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

    #[test]
    fn parses_node_shebang_with_env_and_args() {
        let args =
            node_args_from_shebang("#!/usr/bin/env -S node --no-warnings --trace-deprecation")
                .unwrap();
        assert_eq!(args, vec!["--no-warnings", "--trace-deprecation"]);
    }

    #[test]
    fn ignores_non_node_shebangs() {
        assert_eq!(node_args_from_shebang("#!/usr/bin/env bash"), None);
    }

    #[test]
    fn parses_npm_style_shell_shim_fixture() {
        let dir = tempfile::tempdir().unwrap();
        let shim = dir.path().join("node_modules").join(".bin").join("hello");
        let parsed = parse_node_shell_shim(
            include_str!("../../../tests/fixtures/native/npm-shim.sh"),
            &shim,
        )
        .unwrap();

        assert_eq!(
            parsed,
            NodeBinLaunch {
                script_path: shim.parent().unwrap().join("../hello/cli.js"),
                node_args: vec!["--no-warnings".to_string()],
            }
        );
    }

    #[test]
    fn parses_yarn_style_shell_shim_fixture() {
        let dir = tempfile::tempdir().unwrap();
        let shim = dir.path().join("node_modules").join(".bin").join("tool");
        let parsed = parse_node_shell_shim(
            include_str!("../../../tests/fixtures/native/yarn-shim.sh"),
            &shim,
        )
        .unwrap();

        assert_eq!(
            parsed,
            NodeBinLaunch {
                script_path: shim.parent().unwrap().join("../tool/bin.mjs"),
                node_args: Vec::new(),
            }
        );
    }
}