noxid-cli 0.2.0

The Noxid compiler command line: check, build, test, adapt, and the agent surface
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};

static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);

struct TempDirectory(PathBuf);

impl TempDirectory {
    fn new() -> Self {
        let nonce = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
        let path = std::env::temp_dir().join(format!(
            "noxid-runtime-pruner-{}-{nonce}-{counter}",
            std::process::id()
        ));
        fs::create_dir_all(&path).unwrap();
        Self(path)
    }
}

impl Drop for TempDirectory {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.0);
    }
}

fn walk_javascript(directory: &Path, visit: &mut impl FnMut(&Path)) {
    let mut entries = fs::read_dir(directory)
        .unwrap_or_else(|error| panic!("cannot read {}: {error}", directory.display()))
        .collect::<Result<Vec<_>, _>>()
        .unwrap();
    entries.sort_by_key(|entry| entry.file_name());
    for entry in entries {
        let path = entry.path();
        let file_type = entry.file_type().unwrap();
        if file_type.is_dir() {
            walk_javascript(&path, visit);
        } else if file_type.is_file()
            && path
                .extension()
                .is_some_and(|value| matches!(value.to_str(), Some("js" | "mjs" | "ts")))
        {
            visit(&path);
        }
    }
}

fn named_runtime_imports(source: &str) -> BTreeSet<String> {
    let mut imports = BTreeSet::new();
    let mut remaining = source;
    while let Some(start) = remaining.find("import {") {
        remaining = &remaining[start + "import {".len()..];
        let Some(close) = remaining.find('}') else {
            break;
        };
        let bindings = &remaining[..close];
        let tail = &remaining[close + 1..];
        let Some(statement_end) = tail.find(';') else {
            break;
        };
        let statement = &tail[..statement_end];
        if statement.contains("noxid-runtime.js") {
            for binding in bindings
                .split(',')
                .map(str::trim)
                .filter(|name| !name.is_empty())
            {
                let imported = binding.split_ascii_whitespace().next().unwrap();
                imports.insert(imported.to_string());
            }
        }
        remaining = &tail[statement_end + 1..];
    }
    imports
}

fn runtime_exports(source: &str) -> BTreeSet<String> {
    source
        .lines()
        .filter_map(|line| {
            let declaration = line.trim_start().strip_prefix("export ")?;
            let declaration = declaration.strip_prefix("async ").unwrap_or(declaration);
            let name = ["function ", "class ", "const ", "let "]
                .into_iter()
                .find_map(|prefix| declaration.strip_prefix(prefix))?
                .split(|character: char| !character.is_ascii_alphanumeric() && character != '_')
                .next()?;
            (!name.is_empty()).then(|| name.to_string())
        })
        .collect()
}

fn bundled_runtime_exports(source: &str, known: &BTreeSet<String>) -> BTreeSet<String> {
    // Farm inlines the pruned module and records each retained ESM export in
    // its module-registration table. Those names are the adapted artifact's
    // concrete runtime surface when no source-level import remains.
    known
        .iter()
        .filter(|name| {
            source.contains(&format!(",\"{name}\",()=>"))
                || source.contains(&format!(",'{name}',()=>"))
        })
        .cloned()
        .collect()
}

fn assert_runtime_imports_survive(label: &str, output: &Path, require_imports: bool) {
    let known = runtime_exports(noxid_runtime::RUNTIME_JS);
    let mut requested_runtime = BTreeSet::new();
    let mut runtime_modules = Vec::new();
    walk_javascript(output, &mut |path| {
        if path
            .file_name()
            .is_some_and(|value| value == "noxid-runtime.js")
        {
            return;
        }
        let source = fs::read_to_string(path).unwrap();
        let module_imports = named_runtime_imports(&source);
        let bundled_exports = bundled_runtime_exports(&source, &known);
        if !module_imports.is_empty() || !bundled_exports.is_empty() {
            runtime_modules.push(path.strip_prefix(output).unwrap().display().to_string());
            requested_runtime.extend(module_imports);
            requested_runtime.extend(bundled_exports);
        }
    });
    if require_imports {
        assert!(
            !requested_runtime.is_empty(),
            "{label} emitted neither named noxid-runtime.js imports nor Farm-bundled runtime exports"
        );
    }

    let requested = requested_runtime
        .iter()
        .map(String::as_str)
        .collect::<Vec<_>>();
    let pruned = noxid_runtime::javascript_for(requested.iter().copied());
    let exports = runtime_exports(&pruned);
    for imported in &requested_runtime {
        assert!(
            exports.contains(imported),
            "{label} retains runtime export `{imported}` in [{}], but javascript_for({requested:?}) drops its export",
            runtime_modules.join(", ")
        );
    }
}

fn run_noxid(project: &Path, arguments: &[&str], out_dir: &Path) {
    let (command, options) = arguments.split_first().expect("noxid command");
    let output = Command::new(env!("CARGO_BIN_EXE_noxid"))
        .arg(command)
        .arg(project)
        .args(options)
        .arg("--out-dir")
        .arg(out_dir)
        .env_remove("DATABASE_URL")
        .env_remove("VERCEL")
        .env_remove("NETLIFY")
        .env_remove("RAILWAY_ENVIRONMENT")
        .output()
        .unwrap();
    assert!(
        output.status.success(),
        "example project {} failed to run `noxid {}`:\nstdout:\n{}\nstderr:\n{}",
        project.display(),
        arguments.join(" "),
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
}

#[test]
fn feature_markers_are_flat_and_balanced() {
    let mut open: Option<&str> = None;
    for (index, line) in noxid_runtime::RUNTIME_JS.lines().enumerate() {
        let line_number = index + 1;
        if let Some(feature) = line.trim().strip_prefix("// noxid-runtime:feature-start:") {
            assert!(
                open.is_none(),
                "runtime feature `{feature}` starts at line {line_number} inside feature `{}`",
                open.unwrap()
            );
            open = Some(feature);
        } else if let Some(feature) = line.trim().strip_prefix("// noxid-runtime:feature-end:") {
            assert_eq!(
                open.take(),
                Some(feature),
                "runtime feature `{feature}` ends at line {line_number} without its matching flat start"
            );
        }
    }
    assert_eq!(open, None, "runtime feature `{}` never ends", open.unwrap());
}

#[test]
fn every_example_project_build_and_adapter_runtime_import_survives_pruning() {
    let repository = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
    let examples = repository.join("examples");
    let temp = TempDirectory::new();
    let mut projects = fs::read_dir(&examples)
        .unwrap()
        .collect::<Result<Vec<_>, _>>()
        .unwrap()
        .into_iter()
        .map(|entry| entry.path())
        .filter(|path| path.is_dir() && path.join("Noxid.toml").is_file())
        .collect::<Vec<_>>();
    projects.sort();
    assert!(
        !projects.is_empty(),
        "expected compile-tested example projects"
    );

    for project in &projects {
        let name = project.file_name().unwrap().to_string_lossy();
        let built = temp.0.join("build").join(name.as_ref());
        run_noxid(project, &["build"], &built);
        // A server-only project (uploads has no routes or components) ships no
        // client bundle, so there is no runtime import to survive pruning.
        let ships_client_runtime = name != "uploads";
        assert_runtime_imports_survive(
            &format!("example project {name} build"),
            &built,
            ships_client_runtime,
        );

        let adapted = temp.0.join("adapt-node").join(name.as_ref());
        run_noxid(project, &["adapt", "--adapter", "node"], &adapted);
        assert_runtime_imports_survive(
            &format!("example project {name} node adaptation"),
            &adapted,
            ships_client_runtime && name != "attachments",
        );
        if name == "attachments" {
            let mut attachment_runtime = false;
            walk_javascript(&adapted, &mut |path| {
                attachment_runtime |= fs::read_to_string(path)
                    .unwrap()
                    .contains("@formkit/auto-animate returned an invalid attachment controller");
            });
            assert!(
                attachment_runtime,
                "attachments node adaptation dropped its Farm-minified attachment runtime"
            );
        }
    }

    let portable = examples.join("stream-ssr");
    for adapter in ["node", "deno", "cloudflare", "vercel", "netlify"] {
        let adapted = temp.0.join("adapt-matrix").join(adapter);
        run_noxid(&portable, &["adapt", "--adapter", adapter], &adapted);
        assert!(
            adapted.join("stream-ssr/server/handler.js").is_file(),
            "stream-ssr {adapter} adaptation omitted its server handler"
        );
        assert_runtime_imports_survive(&format!("stream-ssr {adapter} adaptation"), &adapted, true);
    }
}