lib.rs raw

//! Component-file compiler core: s-expression reader, AST, expression
//! language, validation, and IR.
//!
//! This crate has no dependencies and no knowledge of taffy or the widget
//! crates, so it builds fast inside the proc-macro chain and is equally
//! usable by the runtime interpreter.

pub mod accel;
pub mod asset;
pub mod ast;
pub mod diagnostics;
pub mod expr;
pub mod ir;
pub mod manifest;
pub mod parse;
pub mod path;
pub mod registry;
pub mod resolve;
pub mod rich;
pub mod sexpr;
pub mod theme;
pub mod validate;

use std::collections::BTreeMap;

use diagnostics::Diagnostic;
use ir::{Compiled, Ir};
use registry::Registry;

/// Resolves a component reference (`CounterButton`) to the source of its
/// file. The filesystem stays in the callers — the proc-macro and the dev
/// runtime each bring their own — so the compiler core remains pure.
pub trait ImportResolver {
    /// Return the display name (for diagnostics; typically the path) and
    /// source text of the named component's file.
    fn resolve(&mut self, name: &str) -> Result<(String, String), String>;
}

/// The default component search path: one directory of this name under the
/// crate root, unless Cargo.toml's `[package.metadata.guiduck]`
/// `component-path` says otherwise.
pub const DEFAULT_COMPONENT_DIR: &str = "components";

/// The default asset search path: one directory of this name under the crate
/// root, unless Cargo.toml's `[package.metadata.guiduck]` `asset-path` says
/// otherwise. `(asset "icons/logo.png")` names a file within it.
pub const DEFAULT_ASSET_DIR: &str = "assets";

/// The default widget-manifest search path: one directory of this name under
/// the crate root, unless Cargo.toml's `[package.metadata.guiduck]`
/// `widget-path` says otherwise.
///
/// Unlike a component, a widget manifest is not resolved by reference: the
/// vocabulary must be known *before* anything is parsed, since it decides what
/// counts as a widget name at all. So every `.gdw` on this path is loaded up
/// front, and a crate's widget set may live in one file or many.
pub const DEFAULT_WIDGET_DIR: &str = "widgets";

/// The extension of a widget manifest.
pub const WIDGET_MANIFEST_EXT: &str = "gdw";

/// The conventional file holding a component: the declared name verbatim —
/// `CounterButton` lives in `CounterButton.gdc` in one of the search-path
/// directories.
pub fn component_file_name(name: &str) -> String {
    format!("{name}.gdc")
}

/// The generated logic method the framework calls once the component has
/// mounted. It is the framework's, so a handler or a child component whose
/// generated method would take this name is a diagnostic rather than a
/// silently shadowed hook.
pub const MOUNT_HOOK_METHOD: &str = "mounted";

/// The generated logic-factory method for a child component, by Rust
/// method convention: `CounterButton` → `counter_button` (runs of capitals
/// stay together: `HTTPStatus` → `http_status`).
pub fn component_method_name(name: &str) -> String {
    let chars: Vec<char> = name.chars().collect();
    let mut out = String::new();
    for (i, c) in chars.iter().enumerate() {
        if c.is_ascii_uppercase()
            && i > 0
            && (!chars[i - 1].is_ascii_uppercase()
                || chars
                    .get(i + 1)
                    .is_some_and(|next| next.is_ascii_lowercase()))
        {
            out.push('_');
        }
        out.push(c.to_ascii_lowercase());
    }
    out
}

/// Whether a name in widget position is a component reference (capitalized)
/// rather than a builtin widget.
pub fn is_component_name(name: &str) -> bool {
    name.chars().next().is_some_and(|c| c.is_ascii_uppercase())
}

/// Compile `.gdc` source to IR: read, parse, validate. Component references
/// are errors here — use [`compile_with`] to resolve them.
pub fn compile(source: &str) -> Result<ir::Ir, Vec<Diagnostic>> {
    struct NoImports;
    impl ImportResolver for NoImports {
        fn resolve(&mut self, _name: &str) -> Result<(String, String), String> {
            Err("component instantiation is not available in this context".into())
        }
    }
    compile_with(source, &mut NoImports).map(|compiled| compiled.root)
}

/// Compile `.gdc` source and, transitively, every component it
/// instantiates, resolving references through `resolver`, against the builtin
/// widget vocabulary alone.
pub fn compile_with(
    source: &str,
    resolver: &mut dyn ImportResolver,
) -> Result<Compiled, Vec<Diagnostic>> {
    compile_with_widgets(source, resolver, &Registry::default())
}

/// Compile `.gdc` source against a vocabulary that includes the widgets an
/// application declared in its `.gdw` manifests.
///
/// The registry reaches every file the compilation touches, imported
/// components included: the widget vocabulary is a property of the *crate*, so
/// a component may use its own crate's widgets wherever it is instantiated
/// from.
pub fn compile_with_widgets(
    source: &str,
    resolver: &mut dyn ImportResolver,
    registry: &Registry,
) -> Result<Compiled, Vec<Diagnostic>> {
    let component = parse::parse(source).map_err(|d| vec![d])?;
    let mut components = BTreeMap::new();
    let mut stack = vec![component.name.name.clone()];
    let mut errors = Vec::new();
    resolve_imports(
        &component.root,
        resolver,
        &mut components,
        &mut stack,
        registry,
        &mut errors,
    );
    if !errors.is_empty() {
        return Err(errors);
    }
    let root = validate::validate(&component, &components, registry)?;
    Ok(Compiled { root, components })
}

/// Walk a widget tree for component references; compile each one (and its
/// own references, post-order) into `components`.
fn resolve_imports(
    node: &ast::Node,
    resolver: &mut dyn ImportResolver,
    components: &mut BTreeMap<String, Ir>,
    stack: &mut Vec<String>,
    registry: &Registry,
    errors: &mut Vec<Diagnostic>,
) {
    let name = &node.widget.name;
    if is_component_name(name) && !components.contains_key(name) {
        if stack.iter().any(|entry| entry == name) {
            errors.push(Diagnostic::new(
                format!(
                    "component instantiation cycle: {} → {name}",
                    stack.join(" → ")
                ),
                node.widget.span,
            ));
        } else {
            match resolver.resolve(name) {
                Err(why) => errors.push(Diagnostic::new(
                    format!("cannot resolve component `{name}`: {why}"),
                    node.widget.span,
                )),
                Ok((display, child_source)) => {
                    match compile_import(
                        name,
                        &display,
                        &child_source,
                        resolver,
                        components,
                        stack,
                        registry,
                    ) {
                        Ok(child) => {
                            components.insert(name.clone(), child);
                        }
                        Err(diag) => errors.push(Diagnostic::new(diag, node.widget.span)),
                    }
                }
            }
        }
    }
    for child in &node.children {
        resolve_imports(child, resolver, components, stack, registry, errors);
    }
}

/// Compile one imported component file. Its own diagnostics render against
/// its own source and come back as one message for the referencing span.
fn compile_import(
    name: &str,
    display: &str,
    source: &str,
    resolver: &mut dyn ImportResolver,
    components: &mut BTreeMap<String, Ir>,
    stack: &mut Vec<String>,
    registry: &Registry,
) -> Result<Ir, String> {
    let broken = |diags: &[Diagnostic]| {
        let rendered = diagnostics::render(diags, source, display);
        let indented: Vec<String> = rendered.lines().map(|l| format!("  {l}")).collect();
        format!(
            "component `{name}` ({display}) does not compile:\n{}",
            indented.join("\n")
        )
    };
    let component = parse::parse(source).map_err(|d| broken(&[d]))?;
    if component.name.name != name {
        return Err(format!(
            "{display} declares component `{}`, expected `{name}`",
            component.name.name
        ));
    }
    stack.push(name.to_owned());
    let mut child_errors = Vec::new();
    resolve_imports(
        &component.root,
        resolver,
        components,
        stack,
        registry,
        &mut child_errors,
    );
    stack.pop();
    if !child_errors.is_empty() {
        return Err(broken(&child_errors));
    }
    validate::validate(&component, components, registry).map_err(|diags| broken(&diags))
}