cyrs-ast 0.1.0

Typed AST for Cypher / GQL, codegen'd from cyrs-syntax (spec 0001 §5).
Documentation
//! `cyrs-ast` — typed AST wrappers over the CST (spec 0001 §5).
//!
//! AST nodes are zero-cost wrappers around [`cyrs_syntax::SyntaxNode`].
//! A node's methods navigate the underlying tree; no duplication, no
//! allocation. Per §5.2, the bulk of wrappers is generated from a
//! grammar description by a dev-only `xtask`; the output lives in the
//! checked-in [`generated`] module and is regenerated by
//! `cargo xtask codegen`.

// Embedders: see ../../docs/integration-depth.md before depending on this surface.

#![forbid(unsafe_code)]
#![doc(html_root_url = "https://docs.rs/cyrs-ast/0.0.1")]

use cyrs_syntax::{SyntaxElement, SyntaxKind, SyntaxNode, SyntaxToken};

pub mod generated;
pub use generated::*;

// --- Hand-written extensions ---------------------------------------------
//
// The codegen emitter (xtask::codegen) does not yet handle ungrammar
// alternations whose arms are *sequences* — `MapProjectionItem` falls into
// that case (`'.' (PropertyKey | '*') | '*' | key:PropertyKey ':' value:Expr`).
// Until cy-pbx is extended, the wrapper and its child accessors are
// hand-written here. This block is small and self-contained so a future
// codegen extension can lift it into `generated.rs` without churn.

/// Typed wrapper for one item inside a [`MapProjection`].
///
/// Spec §6.1 / §19 row "Map projection" (cy-01q). Each item is one of
/// the four [`MapProjectionItemKind`] shapes; classify with [`Self::kind`]
/// and use the kind-specific accessors.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct MapProjectionItem {
    syntax: SyntaxNode,
}

/// The four shapes a map-projection item can take.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MapProjectionItemKind {
    /// `.NAME` — property selector. Pulls `subject.NAME` into the
    /// projection under key `NAME`.
    PropertySelector,
    /// `key: Expr` — literal item. Inserts `Expr` under key `key`.
    Literal,
    /// `.*` — all-properties spread of the subject.
    AllPropertiesSpread,
    /// `*` — all-bound-vars spread (rare openCypher form).
    AllBoundVarsSpread,
}

impl MapProjectionItem {
    /// Try to cast a raw `SyntaxNode` to a `MapProjectionItem`. Returns
    /// `None` unless `syntax.kind() == MAP_PROJECTION_ITEM`.
    pub fn cast(syntax: SyntaxNode) -> Option<Self> {
        (syntax.kind() == SyntaxKind::MAP_PROJECTION_ITEM).then_some(Self { syntax })
    }

    /// The underlying `SyntaxNode`.
    pub fn syntax(&self) -> &SyntaxNode {
        &self.syntax
    }

    /// Classify this item by inspecting its leading non-trivia tokens.
    /// The four shapes are unambiguous once we know whether the first
    /// significant token is `.`, `*`, or an identifier — see the parser's
    /// `map_projection_item` for the inverse.
    pub fn kind(&self) -> MapProjectionItemKind {
        let mut toks = self
            .syntax
            .children_with_tokens()
            .filter_map(SyntaxElement::into_token)
            .filter(|t| !t.kind().is_trivia());
        match toks.next().map(|t| t.kind()) {
            Some(SyntaxKind::DOT) => match toks.next().map(|t| t.kind()) {
                Some(SyntaxKind::STAR) => MapProjectionItemKind::AllPropertiesSpread,
                _ => MapProjectionItemKind::PropertySelector,
            },
            Some(SyntaxKind::STAR) => MapProjectionItemKind::AllBoundVarsSpread,
            _ => MapProjectionItemKind::Literal,
        }
    }

    /// For `PropertySelector` and `Literal` items, return the bound
    /// property-key token. Returns `None` for spread forms or malformed
    /// recovery shapes.
    pub fn key_token(&self) -> Option<SyntaxToken> {
        self.syntax
            .children_with_tokens()
            .filter_map(SyntaxElement::into_token)
            .find(|t| matches!(t.kind(), SyntaxKind::IDENT | SyntaxKind::QUOTED_IDENT))
    }

    /// For `Literal` items only, return the value expression.
    /// Returns `None` for the other three kinds.
    pub fn value(&self) -> Option<Expr> {
        if matches!(self.kind(), MapProjectionItemKind::Literal) {
            self.syntax.children().find_map(Expr::cast)
        } else {
            None
        }
    }
}

/// Hand-written accessor: iterate the [`MapProjectionItem`] children of a
/// [`MapProjection`]. Lives outside `generated.rs` so codegen runs do not
/// clobber it.
impl MapProjection {
    /// Iterate the projection's items in source order. The receiver
    /// expression is reachable via the codegen-emitted [`Self::subject`]
    /// accessor.
    pub fn items(&self) -> impl Iterator<Item = MapProjectionItem> + '_ {
        self.syntax().children().filter_map(MapProjectionItem::cast)
    }
}

/// Trait every typed AST wrapper conforms to. Modelled after the
/// rust-analyzer `AstNode` pattern. The `generated` module emits plain
/// `cast`/`syntax` methods directly (matching the spec §5.1 shape); this
/// trait is the portable handle hand-written or macro-based wrappers use.
pub trait AstNode: Sized {
    /// `true` iff a node with this `kind` can be cast to `Self`.
    fn can_cast(kind: SyntaxKind) -> bool;
    /// Attempt to cast a raw `SyntaxNode` to this typed wrapper.
    /// Returns `None` when the node's `SyntaxKind` doesn't match.
    fn cast(syntax: SyntaxNode) -> Option<Self>;
    /// Return the underlying `SyntaxNode`.  Zero-cost — typed
    /// wrappers are `repr(transparent)`-style views.
    fn syntax(&self) -> &SyntaxNode;
}

#[cfg(test)]
mod tests {
    use super::*;
    use cyrs_syntax::parse;

    #[test]
    fn cast_source_file_from_generated() {
        let parse = parse("");
        // `SourceFile` comes from `generated::*`.
        let src = SourceFile::cast(parse.syntax()).expect("SOURCE_FILE root");
        // Trivial sanity — confirm the syntax handle round-trips.
        assert_eq!(src.syntax().kind(), SyntaxKind::SOURCE_FILE);
    }
}