windjammer 0.48.0

A simple language inspired by Go, Ruby, and Elixir that transpiles to Rust - 80% of Rust's power with 20% of the complexity
Documentation
//! Assembling `[package]`, `[dependencies]`, and target sections for generated Cargo.toml files.

use crate::compiler::write_if_changed;
use anyhow::Result;
use std::fs;
use std::path::{Path, PathBuf};

use super::dependency_management::{
    dep_spec_to_cargo_line, detect_external_crate_deps, find_windjammer_runtime_path,
    path_to_toml_string, propagate_source_cargo_deps, walk_rs_files,
};
use super::feature_management::{wasm_output_needs_runtime, WEB_SYS_CARGO_FEATURES};

/// Search for `wj.toml` starting from `source_dir` and walking up parents.
pub(crate) fn find_wj_config(source_dir: &Path) -> crate::config::WjConfig {
    let mut dir = source_dir;
    loop {
        let candidate = dir.join("wj.toml");
        if candidate.exists() {
            if let Ok(cfg) = crate::config::WjConfig::load_from_file(&candidate) {
                return cfg;
            }
        }
        match dir.parent() {
            Some(parent) if parent != dir => dir = parent,
            _ => break,
        }
    }
    crate::config::WjConfig::default()
}

pub(crate) fn write_cargo_toml(
    output_dir: &Path,
    source_dir: &Path,
    lib_or_bin_section: &str,
) -> Result<()> {
    let wj_config = find_wj_config(source_dir);

    let runtime_path = find_windjammer_runtime_path();
    let runtime_path_str = path_to_toml_string(&runtime_path);

    let mut deps = vec![
        format!("windjammer-runtime = {{ path = \"{}\" }}", runtime_path_str),
        "smallvec = \"1.13\"".to_string(),
        "serde = { version = \"1.0\", features = [\"derive\"] }".to_string(),
    ];

    // Detect external crate imports from generated Rust source files
    let external_deps = detect_external_crate_deps(output_dir, source_dir);
    deps.extend(external_deps);

    // Propagate dependencies from source project's Cargo.toml (FFI deps, etc.)
    let propagated = propagate_source_cargo_deps(source_dir, &deps);
    deps.extend(propagated);

    // Merge dependencies declared in wj.toml
    let existing_dep_names: std::collections::HashSet<String> = deps
        .iter()
        .filter_map(|d| d.split('=').next().map(|n| n.trim().to_string()))
        .collect();
    for (name, spec) in &wj_config.dependencies {
        if !existing_dep_names.contains(name) {
            deps.push(dep_spec_to_cargo_line(name, spec));
        }
    }

    let project_name = infer_project_name(source_dir);
    let inferred_snake = project_name.replace('-', "_");
    let config_name = wj_config
        .project
        .as_ref()
        .map(|p| &p.name)
        .filter(|n| !n.is_empty())
        .or_else(|| {
            let n = &wj_config.package.name;
            if n.is_empty() {
                None
            } else {
                Some(n)
            }
        });
    let package_name = if let Some(name) = config_name {
        name.replace('-', "_")
    } else {
        resolve_package_name_with_existing_cargo(output_dir, &inferred_snake)
    };

    // Filter out self-referencing dependencies (crate depending on itself).
    let package_name_underscore = package_name.replace('-', "_");
    deps.retain(|dep| {
        let dep_name = dep.split('=').next().unwrap_or("").trim();
        let dep_name_underscore = dep_name.replace('-', "_");
        dep_name_underscore != package_name_underscore
    });

    let deps_section = format!("[dependencies]\n{}\n\n", deps.join("\n"));

    // Build [dev-dependencies] section from wj.toml
    let dev_deps_section = if wj_config.dev_dependencies.is_empty() {
        String::new()
    } else {
        let lines: Vec<String> = wj_config
            .dev_dependencies
            .iter()
            .map(|(name, spec)| dep_spec_to_cargo_line(name, spec))
            .collect();
        format!("[dev-dependencies]\n{}\n\n", lines.join("\n"))
    };

    let cargo_toml = format!(
        r#"# Auto-generated by Windjammer compiler - do not edit manually
[package]
name = "{}"
version = "0.1.0"
edition = "2021"

# Prevent this from being treated as part of parent workspace
[workspace]

{}{}{}[profile.release]
opt-level = 3
"#,
        package_name, deps_section, dev_deps_section, lib_or_bin_section
    );

    let cargo_toml_path = output_dir.join("Cargo.toml");
    write_if_changed(&cargo_toml_path, &cargo_toml)?;

    Ok(())
}

/// Relative path to the crate root Rust file for a `cdylib` WASM build.
pub(crate) fn resolve_wasm_lib_path(output_dir: &Path) -> Result<String> {
    if output_dir.join("lib.rs").exists() {
        return Ok("lib.rs".to_string());
    }
    if output_dir.join("mod.rs").exists() {
        return Ok("mod.rs".to_string());
    }

    let mut top_level: Vec<PathBuf> = Vec::new();
    if let Ok(entries) = fs::read_dir(output_dir) {
        for entry in entries.flatten() {
            let p = entry.path();
            if p.extension().and_then(|s| s.to_str()) == Some("rs") {
                let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("");
                if name != "main.rs" {
                    top_level.push(p);
                }
            }
        }
    }
    top_level.sort();
    if let Some(p) = top_level.first() {
        return Ok(p
            .file_name()
            .expect("wasm lib path")
            .to_string_lossy()
            .into_owned());
    }

    let all = walk_rs_files(output_dir)?;
    if all.len() == 1 {
        let rel = all[0].strip_prefix(output_dir)?;
        return Ok(rel.to_string_lossy().replace('\\', "/"));
    }
    for p in &all {
        if p.file_name().and_then(|s| s.to_str()) == Some("lib.rs") {
            let rel = p.strip_prefix(output_dir)?;
            return Ok(rel.to_string_lossy().replace('\\', "/"));
        }
    }

    if all.is_empty() {
        anyhow::bail!("No Rust sources found under output dir for WASM Cargo.toml");
    }
    anyhow::bail!(
        "Cannot pick a single WASM library entry point among {} Rust files",
        all.len()
    )
}

/// Generate `Cargo.toml` for `--target wasm` builds produced by `compiler::build_project_ext`.
///
/// WASM output is Rust (`cdylib`); this matches `create_wasm_cargo_toml` in `main.rs` and the
/// `WasmBackend::generate_additional_files` template, but uses the same runtime path discovery
/// as single-file Rust builds.
pub fn generate_wasm_cargo_toml(output_dir: &Path, source_dir: &Path) -> Result<()> {
    let lib_rel = resolve_wasm_lib_path(output_dir)?;
    let needs_runtime = wasm_output_needs_runtime(output_dir)?;
    let runtime_line = if needs_runtime {
        let runtime_path = find_windjammer_runtime_path();
        let runtime_str = path_to_toml_string(&runtime_path);
        format!(
            "windjammer-runtime = {{ path = \"{}\", features = [\"wasm\"] }}\n",
            runtime_str
        )
    } else {
        String::new()
    };

    let extra_deps = detect_external_crate_deps(output_dir, source_dir);

    let smallvec_line = "smallvec = \"1.13\"\n";
    let extra_section = if extra_deps.is_empty() {
        String::new()
    } else {
        format!("{}\n", extra_deps.join("\n"))
    };

    let project_snake = infer_project_name(source_dir).replace('-', "_");
    let package_name =
        resolve_package_name_with_existing_cargo(output_dir, &format!("{project_snake}_wasm"));

    let cargo_toml = format!(
        r#"# Auto-generated by Windjammer compiler - do not edit manually
[package]
name = "{pkg}"
version = "0.1.0"
edition = "2021"

# Prevent this from being treated as part of parent workspace
[workspace]

[lib]
crate-type = ["cdylib"]
path = "{lib_rel}"

[dependencies]
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
serde-wasm-bindgen = "0.6"
web-sys = {{ version = "0.3", features = [
{web_sys_features}
] }}
js-sys = "0.3"
serde = {{ version = "1.0", features = ["derive"] }}
serde_json = "1.0"
console_error_panic_hook = "0.1"
{smallvec}{runtime}{extra}
[profile.release]
opt-level = "z"
lto = true
"#,
        pkg = package_name,
        lib_rel = lib_rel,
        web_sys_features = WEB_SYS_CARGO_FEATURES,
        smallvec = smallvec_line,
        runtime = runtime_line,
        extra = extra_section,
    );

    write_if_changed(&output_dir.join("Cargo.toml"), &cargo_toml)?;
    Ok(())
}

/// Reads `[package] name = "..."` from existing generated `Cargo.toml` in `output_dir`.
pub(crate) fn read_package_name_from_package_section(content: &str) -> Option<String> {
    let mut in_package = false;
    for line in content.lines() {
        let trimmed = line.trim();
        if trimmed == "[package]" {
            in_package = true;
            continue;
        }
        if trimmed.starts_with('[') && trimmed != "[package]" {
            in_package = false;
        }
        if in_package && trimmed.starts_with("name") {
            let rest = trimmed.strip_prefix("name")?.trim_start();
            let rest = rest.strip_prefix('=')?.trim();
            let value = rest.strip_prefix('"').and_then(|s| s.strip_suffix('"'))?;
            return Some(value.to_string());
        }
    }
    None
}

/// Prefer a non-placeholder name already present in `output_dir/Cargo.toml` so repeated
/// `wj build` does not reset `name` to `windjammer`. A stale `windjammer` entry is ignored
/// so `wj.toml` (via `inferred_snake`) can replace it.
pub(crate) fn resolve_package_name_with_existing_cargo(
    output_dir: &Path,
    inferred_snake: &str,
) -> String {
    let existing_cargo = output_dir.join("Cargo.toml");
    if !existing_cargo.exists() {
        return inferred_snake.to_string();
    }
    let Ok(content) = fs::read_to_string(&existing_cargo) else {
        return inferred_snake.to_string();
    };
    match read_package_name_from_package_section(&content) {
        Some(name) if name != "windjammer" => name.replace('-', "_"),
        _ => inferred_snake.to_string(),
    }
}

/// Infer the project name from `wj.toml` or `game.toml` (legacy), falling back to directory name.
/// Public so other modules (e.g. `main.rs` WASM Cargo.toml generation) can reuse this logic.
pub fn infer_project_name_from(source_dir: &Path) -> String {
    infer_project_name(source_dir)
}

pub(crate) fn infer_project_name(source_dir: &Path) -> String {
    // Check wj.toml, then game.toml, in source_dir and parent
    let config_files = ["wj.toml", "game.toml"];
    let dirs_to_check: Vec<&Path> = {
        let mut v = vec![source_dir];
        if let Some(parent) = source_dir.parent() {
            v.push(parent);
        }
        v
    };

    for dir in &dirs_to_check {
        for config_name in &config_files {
            let config_path = dir.join(config_name);
            if config_path.exists() {
                if let Ok(content) = fs::read_to_string(&config_path) {
                    if let Some(name) = extract_package_name_from_toml(&content) {
                        return name;
                    }
                }
            }
        }
    }

    // Fallback: use directory name instead of hardcoding "windjammer"
    if let Some(dir_name) = source_dir.file_name().and_then(|n| n.to_str()) {
        if dir_name != "src" {
            return sanitize_package_name(&dir_name.to_lowercase().replace(' ', "-"));
        }
        // If source_dir is "src", use the parent directory name
        if let Some(parent) = source_dir.parent() {
            if let Some(parent_name) = parent.file_name().and_then(|n| n.to_str()) {
                return sanitize_package_name(&parent_name.to_lowercase().replace(' ', "-"));
            }
        }
    }

    "windjammer".to_string()
}

/// Ensure a name is a valid Cargo package name: must start with a Unicode XID
/// start character (letter or `_`) and contain only XID continue characters,
/// `-`, or `_`. Strip leading invalid chars and replace remaining invalid chars
/// with `_`. Falls back to "windjammer" if nothing remains.
fn sanitize_package_name(name: &str) -> String {
    let stripped: String = name
        .chars()
        .skip_while(|c| !c.is_alphabetic() && *c != '_')
        .map(|c| {
            if c.is_alphanumeric() || c == '_' || c == '-' {
                c
            } else {
                '_'
            }
        })
        .collect();
    if stripped.is_empty() {
        "windjammer".to_string()
    } else {
        stripped
    }
}

/// Extract `name = "..."` from a TOML file.
/// Supports both `[package]\nname = "..."` (wj.toml) and flat `name = "..."` (game.toml).
fn extract_package_name_from_toml(content: &str) -> Option<String> {
    let mut in_package = false;
    let mut found_any_section = false;

    for line in content.lines() {
        let trimmed = line.trim();
        if trimmed.starts_with('[') {
            found_any_section = true;
            in_package = trimmed == "[package]";
            continue;
        }
        if trimmed.starts_with("name") {
            // Accept if we're in [package] section, or if there are no sections at all (flat format)
            if in_package || !found_any_section {
                return trimmed
                    .split('"')
                    .nth(1)
                    .map(|s| s.to_lowercase().replace(' ', "-"));
            }
        }
    }
    None
}