Skip to content

refactor(docs-rs): Replace regex extraction in ReactAdapter with ast_extract #766

@ssilvius

Description

@ssilvius

Goal

Rewrite crates/rafters-adapters/src/react.rs to use the ast_extract module instead of regex patterns. Add element_tag, has_children, and self_closing fields to ComponentStructure. Delete all LazyLock<Regex> statics and regex helper functions.

Exact Implementation Requirements

Required Struct Changes

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

/// Extracted component structure from source code.
#[derive(Debug, Clone, Default)]
pub struct ComponentStructure {
    pub name: String,
    pub variant_lookup: Vec<(String, String)>,
    pub size_lookup: Vec<(String, String)>,
    pub base_classes: String,
    pub disabled_classes: String,
    pub default_variant: String,
    pub default_size: String,
    pub observed_attributes: Vec<String>,
    // NEW fields:
    /// HTML element tag rendered by the component (e.g., "button", "input", "span").
    /// Discovered from JSX return statement. Defaults to "div" if not found.
    pub element_tag: String,
    /// Whether the component renders children (has {children} in JSX).
    pub has_children: bool,
    /// Whether the root element is self-closing (e.g., <input />).
    pub self_closing: bool,
}

Behavior Requirements

  • ReactAdapter::extract_structure() must:
    1. Call ast_extract::extract_module_level(source) for variant/size lookups, component name, interface props
    2. Parse source with oxc to find the component function body:
      • Walk exported declarations for React.forwardRef() calls -- unwrap to get inner arrow/function
      • Or find exported plain function declarations
    3. Call ast_extract::analyze_jsx_body() on the function body
    4. Call ast_extract::extract_props_from_params() on function parameters
    5. Call ast_extract::extract_local_class_vars() on function body for base/disabled classes
    6. Merge local class vars with module-level extracts (local takes precedence)
    7. Merge interface props with destructured params (union, deduplicated)
  • element_tag defaults to "div" when JSX analysis finds no HTML element
  • has_children defaults to true when not determinable
  • self_closing defaults to false
  • Remove the regex crate from rafters-adapters/Cargo.toml if no other module uses it

What to Delete

  • All LazyLock<Regex> statics: COMPONENT_NAME_RE, RECORD_RE, ENTRY_RE, BASE_CLASSES_CONCAT_RE, BASE_CLASSES_SIMPLE_RE, DISABLED_CLASSES_RE, PROPS_INTERFACE_RE, DESTRUCTURE_RE
  • Functions: extract_component_name(), extract_record(), extract_base_classes(), parse_concatenated_string(), extract_disabled_classes(), extract_attributes()
  • The hardcoded common attributes list: let common = ["variant", "size", "disabled", "loading"];

Error Handling

  • Return TransformError::ParseError if oxc parsing fails
  • Return TransformError::MissingVariants if no variantClasses found (existing behavior preserved)
  • Gracefully handle components without size classes, base classes, or disabled classes (existing behavior)

Acceptance Criteria

Functional Tests Required

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

    #[test]
    fn extracts_variant_classes() {
        // Same test as current -- must still pass
        let source = r#"
const variantClasses: Record<string, string> = {
  default: 'bg-primary text-primary-foreground',
  secondary: 'bg-secondary text-secondary-foreground',
};
export function Button() { return <button />; }
        "#;
        let adapter = ReactAdapter::new();
        let result = adapter.transform(source, "button-preview", &TransformContext::default()).unwrap();
        assert!(result.web_component.contains("bg-primary"));
    }

    #[test]
    fn discovers_element_tag() {
        let source = r#"
const variantClasses = { default: 'bg-primary' };
export function Button({ children }: Props) {
  return <button className="btn">{children}</button>;
}
        "#;
        let adapter = ReactAdapter::new();
        let structure = adapter.extract_structure(source).unwrap();
        assert_eq!(structure.element_tag, "button");
        assert!(structure.has_children);
        assert!(!structure.self_closing);
    }

    #[test]
    fn discovers_self_closing_input() {
        let source = r#"
const variantClasses = { default: 'border' };
export const Input = React.forwardRef<HTMLInputElement, Props>(
  ({ className, ...props }, ref) => {
    return <input ref={ref} className={className} {...props} />;
  }
);
        "#;
        let adapter = ReactAdapter::new();
        let structure = adapter.extract_structure(source).unwrap();
        assert_eq!(structure.element_tag, "input");
        assert!(!structure.has_children);
        assert!(structure.self_closing);
    }

    #[test]
    fn discovers_span_with_children() {
        let source = r#"
const variantClasses = { default: 'bg-primary' };
export function Badge({ children }: Props) {
  return <span className="badge">{children}</span>;
}
        "#;
        let adapter = ReactAdapter::new();
        let structure = adapter.extract_structure(source).unwrap();
        assert_eq!(structure.element_tag, "span");
        assert!(structure.has_children);
    }

    #[test]
    fn defaults_element_tag_to_div() {
        let source = r#"
const variantClasses = { default: '' };
export function Unknown() { return null; }
        "#;
        let adapter = ReactAdapter::new();
        let structure = adapter.extract_structure(source).unwrap();
        assert_eq!(structure.element_tag, "div");
    }

    #[test]
    fn errors_on_missing_variants() {
        // Existing behavior preserved
        let source = "export function Button() { return <button />; }";
        let adapter = ReactAdapter::new();
        let result = adapter.transform(source, "button-preview", &TransformContext::default());
        assert!(matches!(result, Err(TransformError::MissingVariants)));
    }

    #[test]
    fn extracts_props_without_hardcoded_list() {
        let source = r#"
const variantClasses = { default: '' };
interface CardProps {
  variant?: string;
  elevation?: number;
  rounded?: boolean;
  children?: React.ReactNode;
}
export function Card({ variant, elevation, rounded, children }: CardProps) {
  return <div>{children}</div>;
}
        "#;
        let adapter = ReactAdapter::new();
        let structure = adapter.extract_structure(source).unwrap();
        assert!(structure.observed_attributes.contains(&"variant".to_string()));
        assert!(structure.observed_attributes.contains(&"elevation".to_string()));
        assert!(structure.observed_attributes.contains(&"rounded".to_string()));
        // children should NOT be in observed_attributes
        assert!(!structure.observed_attributes.contains(&"children".to_string()));
    }
}

Rust Requirements

  • Must compile with cargo clippy --all-targets -- -D warnings
  • No regex crate usage remaining in react.rs
  • All existing tests updated to pass with new implementation
  • New tests for element discovery

What NOT to Include

  • Changes to generator.rs (separate issue)
  • Knobs/controls (separate issue)
  • inline.rs changes (it has its own regex for a different purpose)

File Locations

  • Implementation: crates/rafters-adapters/src/react.rs
  • Tests: inline #[cfg(test)] mod tests in react.rs
  • Dependency cleanup: crates/rafters-adapters/Cargo.toml (remove regex if unused elsewhere)

Integration Requirements

Dependencies

Success Criteria

  • All existing tests pass (updated for new API)
  • New element discovery tests pass
  • Zero LazyLock<Regex> in react.rs
  • Zero hardcoded attribute lists
  • cargo clippy -p rafters-adapters --all-targets clean
  • cargo test -p rafters-adapters passes

This issue is complete when: ReactAdapter::extract_structure() discovers element tag, children support, self-closing status, and all props from the AST with zero regex and zero hardcoded assumptions.

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