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))
}