nwnrs 0.0.1

Command-line inspection, conversion, packing, unpacking, and NWScript tooling for Neverwinter Nights resources
Documentation
use std::{
    ffi::OsStr,
    fs,
    path::{Component, Path, PathBuf},
};

use nwnrs_nwscript as nwscript;
use nwnrs_types::prelude::*;
#[cfg(test)]
pub(crate) const DEFAULT_OPTIMIZATION: &str = "O0";

#[derive(Clone)]
pub(crate) struct CompileScriptOptions {
    pub(crate) debug:               bool,
    pub(crate) no_entrypoint_check: bool,
    pub(crate) langspec:            Option<PathBuf>,
    pub(crate) include_dirs:        Vec<PathBuf>,
    pub(crate) optimization:        nwscript::OptimizationLevel,
}

pub(crate) enum CompileScriptOutcome {
    Compiled(nwscript::CompileArtifacts),
    SkippedNoEntrypoint,
}

pub(crate) fn parse_optimization_level(value: &str) -> Result<nwscript::OptimizationLevel, String> {
    match value.to_ascii_uppercase().as_str() {
        "O0" => Ok(nwscript::OptimizationLevel::O0),
        "O1" => Ok(nwscript::OptimizationLevel::O1),
        "O2" => Ok(nwscript::OptimizationLevel::O2),
        "O3" => Ok(nwscript::OptimizationLevel::O3),
        _ => Err(format!("unsupported optimization level: {value}")),
    }
}

pub(crate) fn compile_script_file(
    input: &Path,
    options: &CompileScriptOptions,
) -> Result<nwscript::CompileArtifacts, String> {
    match compile_script_file_with_skip(input, options, false)? {
        CompileScriptOutcome::Compiled(artifacts) => Ok(artifacts),
        CompileScriptOutcome::SkippedNoEntrypoint => Err(format!(
            "failed to compile {}: script did not define an entrypoint",
            input.display()
        )),
    }
}

pub(crate) fn compile_script_file_with_skip(
    input: &Path,
    options: &CompileScriptOptions,
    skip_missing_entrypoint: bool,
) -> Result<CompileScriptOutcome, String> {
    if !input.is_file() {
        return Err(format!("input source does not exist: {}", input.display()));
    }

    let langspec_path =
        resolve_langspec_path(input, options.langspec.as_deref(), &options.include_dirs)?;
    let langspec_bytes = fs::read(&langspec_path)
        .map_err(|error| format!("failed to read {}: {error}", langspec_path.display()))?;
    let langspec =
        nwscript::parse_langspec_bytes(&langspec_path.display().to_string(), &langspec_bytes)
            .map_err(|error| format!("failed to parse {}: {error}", langspec_path.display()))?;

    let search_roots = script_search_roots(input, &options.include_dirs);
    let resolver = FilesystemScriptResolver::new(search_roots);
    let root_name = input
        .file_name()
        .and_then(OsStr::to_str)
        .ok_or_else(|| format!("input file name is not valid UTF-8: {}", input.display()))?;
    let bundle =
        nwscript::load_source_bundle(&resolver, root_name, nwscript::SourceLoadOptions::default())
            .map_err(|error| {
                format!(
                    "failed to load source bundle for {}: {error}",
                    input.display()
                )
            })?;
    let script = nwscript::parse_source_bundle(&bundle, Some(&langspec))
        .map_err(|error| format!("failed to parse {}: {error}", input.display()))?;
    if skip_missing_entrypoint && !script_has_entrypoint(&script) {
        return Ok(CompileScriptOutcome::SkippedNoEntrypoint);
    }

    let compile_options = nwscript::CompileOptions {
        semantic:     nwscript::SemanticOptions {
            require_entrypoint:       !options.no_entrypoint_check,
            allow_conditional_script: true,
        },
        optimization: options.optimization,
    };

    if options.debug {
        nwscript::compile_script_with_source_map(
            &script,
            &bundle.source_map,
            bundle.root_id,
            Some(&langspec),
            compile_options,
        )
    } else {
        nwscript::compile_script(&script, Some(&langspec), compile_options)
    }
    .map(CompileScriptOutcome::Compiled)
    .map_err(|error| format!("failed to compile {}: {error}", input.display()))
}

fn resolve_langspec_path(
    input: &Path,
    explicit: Option<&Path>,
    include_dirs: &[PathBuf],
) -> Result<PathBuf, String> {
    if let Some(path) = explicit {
        if !path.is_file() {
            return Err(format!("langspec file does not exist: {}", path.display()));
        }
        return Ok(path.to_path_buf());
    }

    for root in script_search_roots(input, include_dirs) {
        for candidate in [
            root.join("nwscript.nss"),
            root.join(nwscript::DEFAULT_LANGSPEC_SCRIPT_NAME),
        ] {
            if candidate.is_file() {
                return Ok(candidate);
            }
        }
    }

    Err("failed to find nwscript.nss; pass --langspec explicitly".to_string())
}

fn script_search_roots(input: &Path, include_dirs: &[PathBuf]) -> Vec<PathBuf> {
    let mut roots = Vec::new();
    if let Some(parent) = input.parent() {
        roots.push(parent.to_path_buf());
    }
    for dir in include_dirs {
        if !dir.as_os_str().is_empty() && !roots.contains(dir) {
            roots.push(dir.clone());
        }
    }
    roots
}

struct FilesystemScriptResolver {
    search_roots: Vec<PathBuf>,
}

impl FilesystemScriptResolver {
    fn new(mut search_roots: Vec<PathBuf>) -> Self {
        search_roots.retain(|path| !path.as_os_str().is_empty());
        Self {
            search_roots,
        }
    }

    fn read_candidate(path: &Path) -> Result<Option<Vec<u8>>, nwscript::SourceError> {
        let Some(resolved) = resolve_case_insensitive(path) else {
            return Ok(None);
        };
        fs::read(&resolved).map(Some).map_err(|error| {
            nwscript::SourceError::resolver(format!(
                "failed to read {}: {error}",
                resolved.display()
            ))
        })
    }
}

impl nwscript::ScriptResolver for FilesystemScriptResolver {
    fn resolve_script_bytes(
        &self,
        script_name: &str,
        _res_type: resman::ResType,
    ) -> Result<Option<Vec<u8>>, nwscript::SourceError> {
        let path = Path::new(script_name);
        let mut candidates = Vec::new();

        if path.is_absolute() {
            candidates.push(path.to_path_buf());
            if path.extension().is_none() {
                candidates.push(path.with_extension("nss"));
            }
        } else {
            for root in &self.search_roots {
                let joined = root.join(path);
                candidates.push(joined.clone());
                if joined.extension().is_none() {
                    candidates.push(joined.with_extension("nss"));
                }
            }
        }

        for candidate in candidates {
            if let Some(bytes) = Self::read_candidate(&candidate)? {
                return Ok(Some(bytes));
            }
        }
        Ok(None)
    }
}

fn resolve_case_insensitive(path: &Path) -> Option<PathBuf> {
    if path.is_file() {
        return Some(path.to_path_buf());
    }

    let mut current = PathBuf::new();
    for component in path.components() {
        match component {
            Component::Prefix(prefix) => current.push(prefix.as_os_str()),
            Component::RootDir => current.push(component.as_os_str()),
            Component::CurDir => {}
            Component::ParentDir => current.push(".."),
            Component::Normal(name) => {
                let search_dir = if current.as_os_str().is_empty() {
                    Path::new(".")
                } else {
                    current.as_path()
                };
                let entries = fs::read_dir(search_dir).ok()?;
                let mut matched = None;
                for entry in entries.flatten() {
                    if entry
                        .file_name()
                        .to_string_lossy()
                        .eq_ignore_ascii_case(&name.to_string_lossy())
                    {
                        matched = Some(entry.path());
                        break;
                    }
                }
                current = matched?;
            }
        }
    }

    current.is_file().then_some(current)
}

fn script_has_entrypoint(script: &nwscript::Script) -> bool {
    script.items.iter().any(|item| match item {
        nwscript::TopLevelItem::Function(function) => {
            function.body.is_some()
                && matches!(function.name.as_str(), "main" | "StartingConditional")
        }
        _ => false,
    })
}

#[cfg(test)]
mod tests {
    use std::{
        fs,
        path::PathBuf,
        time::{SystemTime, UNIX_EPOCH},
    };

    use nwnrs_nwscript::OptimizationLevel;

    use super::{CompileScriptOptions, compile_script_file};

    fn unique_test_dir(prefix: &str) -> PathBuf {
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap_or_default()
            .as_nanos();
        std::env::temp_dir().join(format!("nwnrs-{prefix}-{nanos}"))
    }

    fn minimal_langspec() -> &'static str {
        r#"
#define ENGINE_NUM_STRUCTURES 0

int TRUE = 1;
int FALSE = 0;
"#
    }

    #[test]
    fn compile_helper_produces_ncs_and_ndb_outputs() {
        let temp_dir = unique_test_dir("nwscript-compile");
        fs::create_dir_all(&temp_dir).expect("create temp dir");
        let input = temp_dir.join("test.nss");
        fs::write(temp_dir.join("nwscript.nss"), minimal_langspec()).expect("write langspec");
        fs::write(&input, "int StartingConditional() { return TRUE; }").expect("write input");

        let artifacts = compile_script_file(
            &input,
            &CompileScriptOptions {
                debug:               true,
                no_entrypoint_check: false,
                langspec:            None,
                include_dirs:        Vec::new(),
                optimization:        OptimizationLevel::O0,
            },
        )
        .expect("compile should succeed");

        assert!(!artifacts.ncs.is_empty(), "NCS output should exist");
        assert!(artifacts.ndb.is_some(), "NDB output should exist");

        let _ = fs::remove_dir_all(temp_dir);
    }

    #[test]
    fn compile_helper_resolves_include_directories() {
        let temp_dir = unique_test_dir("nwscript-compile-include");
        let include_dir = temp_dir.join("inc");
        fs::create_dir_all(&include_dir).expect("create include dir");
        let input = temp_dir.join("test.nss");
        fs::write(temp_dir.join("nwscript.nss"), minimal_langspec()).expect("write langspec");
        fs::write(
            include_dir.join("helper.nss"),
            "int helper() { return TRUE; }",
        )
        .expect("write include");
        fs::write(
            &input,
            "#include \"helper\"\nint StartingConditional() { return helper(); }",
        )
        .expect("write input");

        let artifacts = compile_script_file(
            &input,
            &CompileScriptOptions {
                debug:               false,
                no_entrypoint_check: false,
                langspec:            None,
                include_dirs:        vec![include_dir],
                optimization:        OptimizationLevel::O1,
            },
        )
        .expect("compile should succeed");

        assert!(!artifacts.ncs.is_empty(), "NCS output should exist");

        let _ = fs::remove_dir_all(temp_dir);
    }
}