ryo-source 0.2.0

High-speed Rust AST manipulation engine
Documentation
//! Conversion from Pure types back to syn types.
//!
//! This enables code generation from PureFile after parallel modifications.
//! All spans are set to `Span::call_site()` since PureFile doesn't preserve spans.
//!
//! # Module Structure
//!
//! - `helpers` - Common helper functions (ident, parse_path)
//! - `attr` - PureAttribute, PureVis
//! - `item` - PureItem, PureUse, PureUseTree
//! - `function` - PureFn, PureParam, PureGenerics, PureBlock, PureStmt
//! - `expr` - PureExpr, PurePattern, PureMatchArm
//! - `types` - PureType
//! - `structs` - PureStruct, PureFields, PureField, PureEnum, PureVariant
//! - `impls` - PureImpl, PureImplItem
//! - `other` - PureConst, PureStatic, PureTypeAlias, PureMod, PureTrait, PureTraitItem, PureMacro

pub mod helpers;

mod attr;
mod expr;
mod function;
mod impls;
mod item;
mod other;
mod structs;
mod types;

use std::cell::RefCell;

use super::ast::PureFile;

// ---------------------------------------------------------------------
// PureExpr::Verbatim staging registry (B-3)
//
// PureFile::to_source clears the registry, drives `to_syn()` (which
// pushes a `(idx, raw)` pair per encountered PureExpr::Verbatim and
// emits a stub path expression `__ryo_verbatim_expr_<idx>`), and after
// prettyplease finishes formatting, walks the registry to splice the
// raw bytes back over each unique stub identifier. The registry lives
// in thread-local state so we don't have to thread a mutable context
// through the entire `ToSyn` recursion.
//
// Re-entrancy: nested PureFile::to_source calls would corrupt each
// other's indices. This is fine for the current ryo codegen pipeline
// where to_source is the outer-most boundary, and is documented as a
// non-reentrancy contract on `PureFile::to_source`.
// ---------------------------------------------------------------------

thread_local! {
    static VERBATIM_EXPR_REGISTRY: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
    static VERBATIM_STMT_REGISTRY: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
}

/// Push a raw chunk into the registry and return its assigned index.
/// Called from `PureExpr::Verbatim` lowering in `expr.rs`.
pub(crate) fn register_verbatim_expr(raw: String) -> usize {
    VERBATIM_EXPR_REGISTRY.with(|r| {
        let mut v = r.borrow_mut();
        let idx = v.len();
        v.push(raw);
        idx
    })
}

/// Push a raw chunk into the stmt registry. Called from
/// `PureStmt::Verbatim` lowering in `function.rs` (B-3-cont).
pub(crate) fn register_verbatim_stmt(raw: String) -> usize {
    VERBATIM_STMT_REGISTRY.with(|r| {
        let mut v = r.borrow_mut();
        let idx = v.len();
        v.push(raw);
        idx
    })
}

/// Take and clear the current registry. Called from `to_source`
/// after prettyplease has produced the formatted string so the splice
/// pass can match each `__ryo_verbatim_expr_<idx>` stub.
fn take_verbatim_expr_registry() -> Vec<String> {
    VERBATIM_EXPR_REGISTRY.with(|r| std::mem::take(&mut *r.borrow_mut()))
}

/// Take and clear the stmt registry (B-3-cont).
fn take_verbatim_stmt_registry() -> Vec<String> {
    VERBATIM_STMT_REGISTRY.with(|r| std::mem::take(&mut *r.borrow_mut()))
}

/// Wipe both registries. Called from `to_source` before driving
/// `to_syn()` so a previous failed/partial run cannot leak indices.
fn clear_verbatim_expr_registry() {
    VERBATIM_EXPR_REGISTRY.with(|r| r.borrow_mut().clear());
    VERBATIM_STMT_REGISTRY.with(|r| r.borrow_mut().clear());
}

/// Format multiple files in-place using a single rustfmt invocation.
///
/// This is much faster than formatting each file individually, as it avoids
/// spawning a new process for each file.
///
/// Returns Ok(()) if rustfmt succeeds, Err with message otherwise.
pub fn format_files_with_rustfmt<P: AsRef<std::path::Path>>(files: &[P]) -> Result<(), String> {
    use std::process::Command;

    if files.is_empty() {
        return Ok(());
    }

    let output = Command::new("rustfmt")
        .args(["--edition", "2021"])
        .args(files.iter().map(|p| p.as_ref()))
        .output()
        .map_err(|e| format!("Failed to spawn rustfmt: {}", e))?;

    if output.status.success() {
        Ok(())
    } else {
        Err(String::from_utf8_lossy(&output.stderr).to_string())
    }
}

/// Trait for converting Pure types back to syn types.
///
/// All conversions are fallible because they may involve `syn::parse_str`
/// for `Other` variants (expressions, types, patterns stored as strings).
pub trait ToSyn {
    /// `syn` output type produced from `self`.
    type Output;
    /// Convert `self` back into its `syn` representation.
    fn to_syn(&self) -> Result<Self::Output, ToSynError>;
}

/// Error type for ToSyn conversions.
#[derive(Debug, Clone)]
pub enum ToSynError {
    /// Failed to parse a path string (e.g., invalid syntax)
    ParsePath {
        /// Original input string that failed to parse.
        input: String,
        /// Underlying parser message.
        message: String,
    },
    /// Failed to parse an expression
    ParseExpr {
        /// Original input string that failed to parse.
        input: String,
        /// Underlying parser message.
        message: String,
    },
    /// Failed to parse a pattern
    ParsePattern {
        /// Original input string that failed to parse.
        input: String,
        /// Underlying parser message.
        message: String,
    },
    /// Failed to parse a type
    ParseType {
        /// Original input string that failed to parse.
        input: String,
        /// Underlying parser message.
        message: String,
    },
    /// Missing required value
    MissingValue {
        /// Context describing which value was missing.
        context: String,
    },
    /// Other conversion error
    Other {
        /// Free-form error message.
        message: String,
    },
}

impl std::fmt::Display for ToSynError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::ParsePath { input, message } => {
                write!(f, "Failed to parse path '{}': {}", input, message)
            }
            Self::ParseExpr { input, message } => {
                write!(f, "Failed to parse expression '{}': {}", input, message)
            }
            Self::ParsePattern { input, message } => {
                write!(f, "Failed to parse pattern '{}': {}", input, message)
            }
            Self::ParseType { input, message } => {
                write!(f, "Failed to parse type '{}': {}", input, message)
            }
            Self::MissingValue { context } => {
                write!(f, "Missing required value: {}", context)
            }
            Self::Other { message } => write!(f, "Conversion error: {}", message),
        }
    }
}

impl std::error::Error for ToSynError {}

impl PureFile {
    /// Convert to syn::File for code generation.
    pub fn to_syn_file(&self) -> Result<syn::File, ToSynError> {
        self.to_syn()
    }

    /// Generate source code string.
    ///
    /// Returns formatted source code using prettyplease. Falls back to
    /// quote-based stringification if prettyplease panics.
    ///
    /// Note: This method does NOT run rustfmt. For final output, use
    /// `format_files_with_rustfmt()` to batch-format written files.
    ///
    /// `PureItem::Verbatim` handling:
    /// Each `Verbatim` at the top of `self.items` is staged out of the way
    /// before lowering to `syn::File` by substituting a unique sentinel stub
    /// `fn __ryo_verbatim_{N}() {}`. After prettyplease produces formatted
    /// source for the rest of the file, the line containing each sentinel is
    /// replaced with the raw bytes carried by the corresponding `Verbatim`.
    /// This is the only path in ryo that preserves arbitrary raw source text
    /// (DSL macro spacing, `//` line comments, blank lines, etc.) end-to-end
    /// through the codegen pipeline.
    pub fn to_source(&self) -> Result<String, ToSynError> {
        // B-3: clear the thread-local PureExpr::Verbatim registry so
        // a previous (possibly failed) call cannot leak indices into
        // this run. The registry gets populated as `to_syn()` walks
        // expressions below.
        clear_verbatim_expr_registry();

        // Stage Verbatim items into sentinel stubs and record the raw chunks
        // in document order. Stubs use sequentially-indexed names so the
        // post-process splice step can locate them.
        let mut verbatims: Vec<String> = Vec::new();
        let staged_items: Vec<crate::pure::PureItem> = self
            .items
            .iter()
            .map(|item| {
                if let crate::pure::PureItem::Verbatim(raw) = item {
                    let idx = verbatims.len();
                    verbatims.push(raw.clone());
                    crate::pure::PureItem::Other(format!("fn __ryo_verbatim_{}() {{}}", idx))
                } else {
                    item.clone()
                }
            })
            .collect();
        let staged = Self {
            attrs: self.attrs.clone(),
            items: staged_items,
        };

        let file = staged.to_syn()?;

        // Try prettyplease first, fall back to quote if it panics
        let mut out = catch_unwind_silent(|| prettyplease::unparse(&file)).unwrap_or_else(|_| {
            use quote::ToTokens;
            file.to_token_stream().to_string()
        });

        // Splice raw bytes over each sentinel stub. We search for the unique
        // identifier (not the surrounding `fn` syntax) because prettyplease
        // may format the stub in either single-line or multi-line form.
        for (idx, raw) in verbatims.iter().enumerate() {
            let needle = format!("__ryo_verbatim_{}", idx);
            if let Some(pos) = out.find(&needle) {
                let bol = out[..pos].rfind('\n').map(|p| p + 1).unwrap_or(0);
                let eol = out[pos..].find('\n').map(|p| pos + p).unwrap_or(out.len());
                let mut rebuilt = String::with_capacity(out.len() + raw.len());
                rebuilt.push_str(&out[..bol]);
                rebuilt.push_str(raw);
                if !raw.ends_with('\n') {
                    rebuilt.push('\n');
                }
                rebuilt.push_str(&out[eol..]);
                out = rebuilt;
            }
        }

        // B-3 expr-level splice: replace each unique
        // `__ryo_verbatim_expr_<idx>` token with the raw bytes
        // registered during the to_syn walk. Unlike the item-level
        // splice, we replace only the identifier (not the surrounding
        // line) so the raw bytes drop into the expression position
        // verbatim, with the surrounding indent / punctuation that
        // prettyplease laid out around the stub.
        let expr_verbatims = take_verbatim_expr_registry();
        for (idx, raw) in expr_verbatims.iter().enumerate() {
            let needle = format!("__ryo_verbatim_expr_{}", idx);
            if let Some(pos) = out.find(&needle) {
                out.replace_range(pos..pos + needle.len(), raw);
            }
        }

        // B-3-cont stmt-level splice: replace the entire line
        // containing each `__ryo_verbatim_stmt_<idx>;` stub with the
        // raw bytes. Line-splice (like the item-level splice) because
        // a Stmt always occupies its own line in prettyplease output,
        // and the user's raw is expected to carry its own statement
        // terminator (`;` or block).
        let stmt_verbatims = take_verbatim_stmt_registry();
        for (idx, raw) in stmt_verbatims.iter().enumerate() {
            let needle = format!("__ryo_verbatim_stmt_{}", idx);
            if let Some(pos) = out.find(&needle) {
                let bol = out[..pos].rfind('\n').map(|p| p + 1).unwrap_or(0);
                let eol = out[pos..].find('\n').map(|p| pos + p).unwrap_or(out.len());
                let mut rebuilt = String::with_capacity(out.len() + raw.len());
                rebuilt.push_str(&out[..bol]);
                rebuilt.push_str(raw);
                if !raw.ends_with('\n') {
                    rebuilt.push('\n');
                }
                rebuilt.push_str(&out[eol..]);
                out = rebuilt;
            }
        }

        Ok(out)
    }
}

/// Execute a closure, catching any panics without printing panic messages.
///
/// This is useful for handling expected panics from third-party libraries
/// (like prettyplease's Expr::Verbatim) without polluting stderr.
fn catch_unwind_silent<F, R>(f: F) -> std::thread::Result<R>
where
    F: FnOnce() -> R + std::panic::UnwindSafe,
{
    let prev_hook = std::panic::take_hook();
    std::panic::set_hook(Box::new(|_| {}));
    let result = std::panic::catch_unwind(f);
    std::panic::set_hook(prev_hook);
    result
}

impl ToSyn for PureFile {
    type Output = syn::File;

    fn to_syn(&self) -> Result<syn::File, ToSynError> {
        Ok(syn::File {
            shebang: None,
            attrs: self
                .attrs
                .iter()
                .map(|a| a.to_syn())
                .collect::<Result<Vec<_>, _>>()?,
            items: self
                .items
                .iter()
                .map(|i| i.to_syn())
                .collect::<Result<Vec<_>, _>>()?,
        })
    }
}

#[cfg(test)]
mod tests;