cuke-dedup 0.1.0

Static analysis for duplicate and reusable Cucumber step definitions
Documentation
//! Tree-sitter based JavaScript and TypeScript step-definition extraction.

mod handler;
mod matcher;
mod module_resolver;
mod registrations;
mod suppression;

use self::handler::{
    bind_arguments, collect_handler_bindings, fingerprint_handler, resolve_handler, HandlerBinding,
};
#[cfg(test)]
use self::handler::{bounded_source_snippet, MAX_HANDLER_SNIPPET_CHARS};
pub use self::matcher::normalize_matcher;
#[cfg(test)]
use self::matcher::normalize_regular_expression;
use self::matcher::{matcher_value, normalize_matcher_with_flags, rust_regex_supported};
use self::registrations::{
    detect_framework, detect_registrations, registration_name, RegistrationNames,
};
use self::suppression::inline_suppressions;
use crate::model::{Framework, MatcherKind, SourceLocation, StepDefinition};
use crate::source_adapter::{adapter_for_language, SourceAdapter};
pub use crate::source_adapter::{
    Extraction, ExtractionDiagnostic, ExtractionDiagnosticLevel, SourceFile, SourceLanguage,
};
use anyhow::{Context, Result};
use std::collections::BTreeMap;
use tree_sitter::{Node, Parser};

pub(crate) struct TreeSitterSourceAdapter {
    name: &'static str,
    language: SourceLanguage,
}

pub(crate) static JAVASCRIPT_ADAPTER: TreeSitterSourceAdapter = TreeSitterSourceAdapter {
    name: "javascript",
    language: SourceLanguage::JavaScript,
};
pub(crate) static TYPESCRIPT_ADAPTER: TreeSitterSourceAdapter = TreeSitterSourceAdapter {
    name: "typescript",
    language: SourceLanguage::TypeScript,
};
pub(crate) static TSX_ADAPTER: TreeSitterSourceAdapter = TreeSitterSourceAdapter {
    name: "tsx",
    language: SourceLanguage::Tsx,
};

impl SourceAdapter for TreeSitterSourceAdapter {
    fn name(&self) -> &'static str {
        self.name
    }

    fn language(&self) -> SourceLanguage {
        self.language
    }

    fn extract(&self, source: &str, file: &SourceFile) -> Result<Extraction> {
        if file.language != self.language {
            anyhow::bail!(
                "{} adapter cannot parse {:?} source {}",
                self.name,
                file.language,
                file.path.display()
            );
        }
        extract_detailed_impl(source, file)
    }
}

struct AdapterContext<'source, 'tree> {
    source: &'source [u8],
    source_lines: &'source [&'source str],
    file: &'source SourceFile,
    framework: Framework,
    registrations: &'source RegistrationNames,
    handler_bindings: &'source BTreeMap<String, Vec<HandlerBinding<'tree>>>,
}

/// Reads and extracts step definitions from a discovered JavaScript or TypeScript file.
pub fn extract_file(file: &SourceFile) -> Result<Vec<StepDefinition>> {
    Ok(extract_file_detailed(file)?.definitions)
}

/// Reads a source file and returns definitions plus localized extraction diagnostics.
pub fn extract_file_detailed(file: &SourceFile) -> Result<Extraction> {
    adapter_for_language(file.language).extract_file(file)
}

/// Extracts step definitions from in-memory source using `file` for syntax and locations.
pub fn extract(source: &str, file: &SourceFile) -> Result<Vec<StepDefinition>> {
    Ok(extract_detailed(source, file)?.definitions)
}

/// Extracts definitions and localized diagnostics from in-memory source.
pub fn extract_detailed(source: &str, file: &SourceFile) -> Result<Extraction> {
    adapter_for_language(file.language).extract(source, file)
}

fn extract_detailed_impl(source: &str, file: &SourceFile) -> Result<Extraction> {
    let mut parser = Parser::new();
    let language = match file.language {
        SourceLanguage::JavaScript => tree_sitter_javascript::LANGUAGE.into(),
        SourceLanguage::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
        SourceLanguage::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(),
    };
    parser
        .set_language(&language)
        .context("failed to initialize the JavaScript/TypeScript parser")?;
    let tree = parser
        .parse(source, None)
        .context("Tree-sitter did not return a syntax tree")?;

    let root = tree.root_node();
    let source_bytes = source.as_bytes();
    let source_lines: Vec<_> = source.lines().collect();
    let framework = detect_framework(root, source_bytes);
    let registrations = detect_registrations(root, source_bytes, framework, &file.path);
    let mut handler_bindings = BTreeMap::new();
    collect_handler_bindings(root, source_bytes, &mut handler_bindings);
    let context = AdapterContext {
        source: source_bytes,
        source_lines: &source_lines,
        file,
        framework,
        registrations: &registrations,
        handler_bindings: &handler_bindings,
    };
    let mut definitions = Vec::new();
    let mut diagnostics = Vec::new();
    if root.has_error() {
        let syntax_node = first_syntax_error(root).unwrap_or(root);
        diagnostics.push(ExtractionDiagnostic {
            level: ExtractionDiagnosticLevel::Error,
            location: node_location(file, syntax_node, source_bytes),
            message: "source contains JavaScript/TypeScript syntax errors, so analysis is incomplete; fix the syntax, use a .tsx extension for JSX, or narrow definition discovery".to_owned(),
        });
    }
    collect_calls(root, &context, &mut definitions, &mut diagnostics);
    definitions.sort_by(|left, right| {
        left.location
            .line
            .cmp(&right.location.line)
            .then(left.location.column.cmp(&right.location.column))
    });
    Ok(Extraction {
        definitions,
        diagnostics,
    })
}

fn collect_calls<'tree>(
    node: Node<'tree>,
    context: &AdapterContext<'_, 'tree>,
    definitions: &mut Vec<StepDefinition>,
    diagnostics: &mut Vec<ExtractionDiagnostic>,
) {
    let mut stack = vec![node];
    while let Some(node) = stack.pop() {
        if node.kind() == "call_expression" {
            if let Some(definition) = extract_call(node, context, diagnostics) {
                definitions.push(definition);
            }
        }
        let mut cursor = node.walk();
        let children: Vec<_> = node.named_children(&mut cursor).collect();
        stack.extend(children.into_iter().rev());
    }
}

fn extract_call<'tree>(
    call: Node<'tree>,
    context: &AdapterContext<'_, 'tree>,
    diagnostics: &mut Vec<ExtractionDiagnostic>,
) -> Option<StepDefinition> {
    let source = context.source;
    let function = call.child_by_field_name("function")?;
    let (callee, registration) = registration_name(function, source, context.registrations)?;
    let arguments = call.child_by_field_name("arguments")?;
    let mut cursor = arguments.walk();
    let arguments: Vec<_> = arguments.named_children(&mut cursor).collect();
    if arguments.len() < 2 {
        return None;
    }
    let matcher_node = arguments[0];
    let (matcher, matcher_kind, matcher_flags) = match matcher_value(matcher_node, source) {
        Some(value) => value,
        None if matcher_node.kind() == "string" => {
            diagnostics.push(ExtractionDiagnostic {
                level: ExtractionDiagnosticLevel::Error,
                location: node_location(context.file, matcher_node, source),
                message: "invalid JavaScript escape sequence in step matcher".to_owned(),
            });
            return None;
        }
        None => {
            diagnostics.push(ExtractionDiagnostic {
                level: ExtractionDiagnosticLevel::Warning,
                location: node_location(context.file, matcher_node, source),
                message: "dynamic or unsupported step matcher cannot be analyzed statically"
                    .to_owned(),
            });
            return None;
        }
    };
    if matcher_kind == MatcherKind::RegularExpression
        && !rust_regex_supported(&matcher, &matcher_flags)
    {
        diagnostics.push(ExtractionDiagnostic {
            level: ExtractionDiagnosticLevel::Warning,
            location: node_location(context.file, matcher_node, source),
            message: "regular expression uses syntax unsupported by static usage analysis; unused and ambiguity checks will treat this definition as indeterminate".to_owned(),
        });
    }
    let handler = arguments.iter().rev().copied().find(|node| {
        matches!(
            node.kind(),
            "arrow_function"
                | "function_expression"
                | "generator_function"
                | "identifier"
                | "member_expression"
                | "call_expression"
        )
    });
    let Some(handler) = handler else {
        diagnostics.push(ExtractionDiagnostic {
            level: ExtractionDiagnosticLevel::Warning,
            location: node_location(context.file, call, source),
            message: "dynamic or unsupported step handler cannot be compared statically".to_owned(),
        });
        return None;
    };
    let bound_arguments = bind_arguments(handler, source);
    let resolved_handler = resolve_handler(handler, source, context.handler_bindings);
    if resolved_handler.is_none() && handler.kind() == "call_expression" {
        diagnostics.push(ExtractionDiagnostic {
            level: ExtractionDiagnosticLevel::Warning,
            location: node_location(context.file, handler, source),
            message: "dynamic or unsupported step handler cannot be compared statically".to_owned(),
        });
    }
    let comparable = resolved_handler.is_some();
    let handler = resolved_handler.unwrap_or(handler);
    Some(StepDefinition {
        normalized_matcher: normalize_matcher_with_flags(&matcher, matcher_kind, &matcher_flags),
        matcher,
        matcher_kind,
        matcher_flags,
        handler: fingerprint_handler(handler, bound_arguments, source, comparable),
        framework: context.framework,
        registration: if registration.is_empty() {
            callee
        } else {
            registration
        },
        location: node_location(context.file, call, source),
        inline_suppressions: inline_suppressions(
            call,
            context.source_lines,
            context.file,
            diagnostics,
        ),
    })
}

fn node_location(file: &SourceFile, node: Node<'_>, source: &[u8]) -> SourceLocation {
    let start = node.start_position();
    let end = node.end_position();
    SourceLocation::new(
        &file.path,
        start.row + 1,
        unicode_column(source, node.start_byte(), start.column),
        end.row + 1,
        unicode_column(source, node.end_byte(), end.column),
    )
}

fn unicode_column(source: &[u8], byte_offset: usize, byte_column: usize) -> usize {
    let line_start = byte_offset.saturating_sub(byte_column);
    let end = byte_offset.min(source.len());
    std::str::from_utf8(&source[line_start.min(end)..end])
        .map_or(byte_column, |prefix| prefix.chars().count())
        + 1
}

fn first_syntax_error(root: Node<'_>) -> Option<Node<'_>> {
    let mut stack = vec![root];
    while let Some(node) = stack.pop() {
        if node.is_error() || node.is_missing() {
            return Some(node);
        }
        let mut cursor = node.walk();
        let children: Vec<_> = node.named_children(&mut cursor).collect();
        stack.extend(children.into_iter().rev());
    }
    None
}

fn node_text<'a>(node: Node<'_>, source: &'a [u8]) -> &'a str {
    std::str::from_utf8(&source[node.byte_range()]).unwrap_or("")
}

#[cfg(test)]
mod tests;