Skip to content

feat(docs-rs): Create ast_extract module for shared AST extraction with oxc_parser #765

@ssilvius

Description

@ssilvius

Goal

Create crates/rafters-adapters/src/ast_extract.rs -- a shared module that uses oxc_parser (already a workspace dep at v0.72) to extract component metadata from JS/TS source code. This replaces all regex-based extraction and is framework-agnostic: it handles script-level declarations (variantClasses, sizeClasses, baseClasses, props interfaces) that are identical across React, Solid, Preact, and the script sections of Vue/Svelte SFCs. JSX-specific analysis (element tag discovery, children detection) is also included here for use by JSX-based adapters.

Exact Implementation Requirements

Required Struct/Function Signatures

// crates/rafters-adapters/src/ast_extract.rs

use oxc_allocator::Allocator;
use oxc_ast::ast::*;
use oxc_parser::Parser;
use oxc_span::SourceType;

use crate::traits::TransformError;

/// What we learn from module-level JS/TS declarations (any framework).
#[derive(Debug, Clone, Default)]
pub struct ModuleExtracts {
    pub variant_lookup: Vec<(String, String)>,
    pub size_lookup: Vec<(String, String)>,
    pub base_classes: String,
    pub disabled_classes: String,
    pub component_name: Option<String>,
    pub interface_props: Vec<String>,
}

/// What we learn from analyzing JSX output (React/Solid/Preact).
#[derive(Debug, Clone, Default)]
pub struct JsxAnalysis {
    /// The HTML element tag rendered by the component (e.g., "button", "input", "span")
    pub element_tag: Option<String>,
    /// Whether the component renders {children}
    pub has_children: bool,
    /// Whether the root element is self-closing (e.g., <input />)
    pub self_closing: bool,
}

/// Local class variables found inside a component function body.
#[derive(Debug, Clone, Default)]
pub struct LocalClassVars {
    pub base_classes: Option<String>,
    pub disabled_classes: Option<String>,
}

/// Parse JS/TS source with oxc_parser, walk module-level declarations.
/// Extracts variantClasses, sizeClasses, component name, interface props.
pub fn extract_module_level(source: &str) -> Result<ModuleExtracts, TransformError>

/// Extract key-value entries from an ObjectExpression AST node.
/// Handles: `{ key: 'value', key2: "value2" }`
pub fn extract_object_entries(expr: &Expression) -> Option<Vec<(String, String)>>

/// Extract string value from an Expression.
/// Handles: StringLiteral, BinaryExpression (concatenation), TemplateLiteral (no expressions).
pub fn extract_string_value(expr: &Expression) -> Option<String>

/// Extract prop names from destructured function parameters.
/// Skips: children, className, style, ref, key, ...rest patterns.
pub fn extract_props_from_params(params: &FormalParameters) -> Vec<String>

/// Extract prop names from a TypeScript interface declaration.
/// Skips: children, className, style, ref, key.
pub fn extract_props_from_interface(decl: &TSInterfaceDeclaration) -> Vec<String>

/// Extract local class variable declarations from within a function body.
/// Finds patterns: `const base = '...'`, `const baseClasses = '...' + '...'`,
/// `const disabledCls = '...'`, `const disabledClasses = '...'`
pub fn extract_local_class_vars(body: &FunctionBody) -> LocalClassVars

/// Analyze a JSX function body to discover what the component renders.
/// Walks return statements, finds root JSX element.
pub fn analyze_jsx_body(body: &FunctionBody) -> JsxAnalysis

/// Find the first HTML element tag from JSX return statements.
/// Only returns lowercase tags (HTML elements). Uppercase = component references, skipped.
/// Handles: ReturnStatement, ParenthesizedExpression, ConditionalExpression (both branches).
pub fn find_jsx_element_tag(body: &FunctionBody) -> Option<String>

/// Check if JSX children are rendered.
/// Walks JSX children for JSXExpressionContainer containing identifier `children`.
pub fn jsx_has_children(body: &FunctionBody) -> bool

// Internal helper -- not public
/// Unwrap TS wrappers: TSAsExpression, TSSatisfiesExpression, ParenthesizedExpression.
fn unwrap_ts_wrappers(expr: &Expression) -> &Expression

Behavior Requirements

  • Parse with oxc_parser::Parser using SourceType::tsx() -- handles both .ts and .tsx
  • extract_module_level must walk all top-level VariableDeclaration nodes and find variantClasses / sizeClasses by variable name, then call extract_object_entries on the init expression
  • extract_module_level must walk ExportDefaultDeclaration and ExportNamedDeclaration to find the component name (function name or const Name = React.forwardRef(...))
  • extract_module_level must find TSInterfaceDeclaration nodes matching *Props pattern and extract prop names
  • extract_string_value must handle string concatenation ('a' + 'b') by recursively walking BinaryExpression with BinaryOperator::Addition
  • extract_string_value must handle TemplateLiteral with zero expressions (just quasis)
  • find_jsx_element_tag must only return lowercase tag names (HTML elements). Uppercase names are React component references and must be skipped
  • find_jsx_element_tag must handle ConditionalExpression (ternary) by analyzing both consequent and alternate branches
  • jsx_has_children must detect {children} in JSX expression containers at any nesting depth
  • unwrap_ts_wrappers must recursively unwrap TSAsExpression, TSSatisfiesExpression, and ParenthesizedExpression to get the underlying expression
  • All functions must work with real Rafters component patterns (see test cases below)

Error Handling

  • extract_module_level returns TransformError::ParseError if oxc_parser fails
  • extract_object_entries and extract_string_value return None for unrecognized patterns (not errors)
  • Never panic on unexpected AST shapes -- return defaults

Acceptance Criteria

Functional Tests Required

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

    #[test]
    fn extracts_variant_classes_from_record() {
        let source = r#"
const variantClasses: Record<string, string> = {
  default: 'bg-primary text-primary-foreground shadow-sm hover:bg-primary-hover',
  destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
  outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
};
export function Button() { return <button />; }
        "#;
        let result = extract_module_level(source).unwrap();
        assert_eq!(result.variant_lookup.len(), 3);
        assert_eq!(result.variant_lookup[0].0, "default");
        assert!(result.variant_lookup[0].1.contains("bg-primary"));
    }

    #[test]
    fn extracts_size_classes() {
        let source = r#"
const variantClasses = { default: '' };
const sizeClasses: Record<string, string> = {
  sm: 'h-8 rounded-md px-3 text-xs',
  default: 'h-9 px-4 py-2',
  lg: 'h-10 rounded-md px-8',
};
export function Button() { return <button />; }
        "#;
        let result = extract_module_level(source).unwrap();
        assert_eq!(result.size_lookup.len(), 3);
        assert!(result.size_lookup[0].1.contains("h-8"));
    }

    #[test]
    fn extracts_props_from_interface() {
        let source = r#"
interface ButtonProps {
  variant?: string;
  size?: string;
  disabled?: boolean;
  children?: React.ReactNode;
  className?: string;
}
const variantClasses = { default: '' };
export function Button({ variant, size, disabled, children }: ButtonProps) {
  return <button />;
}
        "#;
        let result = extract_module_level(source).unwrap();
        assert!(result.interface_props.contains(&"variant".to_string()));
        assert!(result.interface_props.contains(&"size".to_string()));
        assert!(result.interface_props.contains(&"disabled".to_string()));
        // children, className should be filtered out
        assert!(!result.interface_props.contains(&"children".to_string()));
        assert!(!result.interface_props.contains(&"className".to_string()));
    }

    #[test]
    fn handles_ts_as_expression() {
        let source = r#"
const variantClasses = {
  default: 'bg-primary',
} as Record<string, string>;
export function Button() { return <button />; }
        "#;
        let result = extract_module_level(source).unwrap();
        assert!(!result.variant_lookup.is_empty());
    }

    #[test]
    fn handles_satisfies_expression() {
        let source = r#"
const variantClasses = {
  default: 'bg-primary',
} satisfies Record<string, string>;
export function Button() { return <button />; }
        "#;
        let result = extract_module_level(source).unwrap();
        assert!(!result.variant_lookup.is_empty());
    }
}

Rust Requirements

  • Must compile with cargo clippy --all-targets -- -D warnings
  • No unwrap() on user-provided data -- only on internal invariants
  • No unsafe code
  • All public items documented with /// doc comments
  • Tests use real component source patterns from packages/ui/src/components/ui/

What NOT to Include

  • Framework-specific adapters (separate issue)
  • Web Component generation (separate issue)
  • Knobs/controls (separate issue)
  • Any hardcoded element behaviors or static lookup tables

File Locations

  • Implementation: crates/rafters-adapters/src/ast_extract.rs
  • Module declaration: crates/rafters-adapters/src/lib.rs (add pub mod ast_extract;)
  • Tests: inline #[cfg(test)] mod tests in ast_extract.rs

Integration Requirements

Dependencies

  • oxc_parser, oxc_allocator, oxc_ast, oxc_span v0.72 (already in workspace Cargo.toml)
  • crate::traits::TransformError for error types
  • No new external dependencies

Usage Example

use rafters_adapters::ast_extract::{extract_module_level, analyze_jsx_body};

let source = std::fs::read_to_string("button.tsx").unwrap();
let extracts = extract_module_level(&source)?;
// extracts.variant_lookup, extracts.size_lookup, etc.

Success Criteria

  • All functional tests pass
  • cargo clippy -p rafters-adapters --all-targets clean
  • cargo test -p rafters-adapters passes
  • No regex crate usage in this module
  • Correctly parses real Button, Input, Badge component patterns

This issue is complete when: extract_module_level() and analyze_jsx_body() correctly extract all metadata from real Rafters component source files (Button, Input, Badge patterns) using the oxc AST, with zero regex.

Context & References

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions