devscout-rs 0.10.0

Fast code index for C# and TypeScript codebases -- map, find, refs, impact from the CLI.
use std::collections::HashSet;

use tree_sitter::{Node, Parser};

pub(super) const MAX_PURPOSE: usize = 200;

// (grammar node kind, TYPE_KINDS label) pairs, iterated as an ordered slice
// to preserve declaration order.
const TYPE_KINDS: &[(&str, &str)] = &[
    ("class_declaration", "class"),
    ("interface_declaration", "interface"),
    ("struct_declaration", "struct"),
    ("record_declaration", "record"),
    ("enum_declaration", "enum"),
];

const NAMESPACE_NODES: &[&str] = &["namespace_declaration", "file_scoped_namespace_declaration"];

pub(super) fn type_kind_label(kind: &str) -> Option<&'static str> {
    TYPE_KINDS
        .iter()
        .find(|(k, _)| *k == kind)
        .map(|(_, label)| *label)
}

pub(super) fn new_parser() -> Parser {
    let mut parser = Parser::new();
    parser
        .set_language(&tree_sitter_c_sharp::LANGUAGE.into())
        .expect("failed to load C# grammar");
    parser
}

pub(super) fn named_children<'a>(node: Node<'a>) -> Vec<Node<'a>> {
    let mut cursor = node.walk();
    node.named_children(&mut cursor).collect()
}

pub(super) fn text(node: Node, src: &[u8]) -> String {
    crate::parse::node_text(node, src)
}

pub(super) fn declared_name(node: Node, src: &[u8]) -> String {
    node.child_by_field_name("name")
        .map(|n| text(n, src))
        .unwrap_or_default()
}

// ---------------------------------------------------------------------------
// Token-built dotted names.
// ---------------------------------------------------------------------------

// The dotted name a qualified name's own tokens spell, with the whitespace,
// line breaks and comments between them left out: trivia never belongs to a
// name, and a name written with it must read exactly as its clean spelling
// does, because every consumer splits or compares that string -- the
// resolver's last-dot split, a def-path suffix test, a scope lookup.
//
// Two adjacent word tokens keep one space between them, so a tuple element
// name inside a type-argument list stays two words the way `type_descriptor`
// renders the same list.
//
// `None` when any node in the subtree is an ERROR or MISSING node: a name
// that did not parse gets no token-built path, and its caller then keeps the
// source text it recorded before, so a parse error can never earn a path the
// raw text would not have matched.
pub(super) fn token_path(node: Node, src: &[u8]) -> Option<String> {
    // One gate for the whole subtree, checked before the walk for the reason
    // `push_path_tokens` gives below: this grammar reports an ERROR node as
    // an extra one, so a walk that decided per node would read a damaged
    // name as if the damage were a comment.
    if node.has_error() {
        return None;
    }
    let mut out = String::new();
    if push_path_tokens(node, src, &mut out) {
        Some(out)
    } else {
        None
    }
}

// `token_path`, or the node's own source text when its subtree did not parse
// cleanly.
pub(super) fn path_text(node: Node, src: &[u8]) -> String {
    token_path(node, src).unwrap_or_else(|| text(node, src))
}

// The written text of one name node, read by whichever rule that node earns:
// its token path when the subtree parsed, its source text when it did not. A
// caller that has to split one name against another -- a qualified name
// against its own tail -- reads BOTH sides this way, so the two halves come
// from one reader and a name written with trivia, or with trivia inside its
// type arguments, still divides where it was written to divide.
pub(super) fn name_text(node: Node, src: &[u8]) -> String {
    if node.has_error() {
        text(node, src)
    } else {
        path_text(node, src)
    }
}

fn push_path_tokens(node: Node, src: &[u8], out: &mut String) -> bool {
    // An ERROR node is reported as an extra one too, so the refusal test has
    // to come before the trivia skip.
    if node.is_error() || node.is_missing() {
        return false;
    }
    if node.is_extra() {
        return true;
    }
    if node.child_count() == 0 {
        let piece = text(node, src);
        if piece.chars().next().is_some_and(is_identifier_char)
            && out.chars().last().is_some_and(is_identifier_char)
        {
            out.push(' ');
        }
        out.push_str(&piece);
        return true;
    }
    let mut cursor = node.walk();
    for child in node.children(&mut cursor) {
        if !push_path_tokens(child, src, out) {
            return false;
        }
    }
    true
}

fn is_identifier_char(c: char) -> bool {
    c.is_alphanumeric() || c == '_'
}

// ---------------------------------------------------------------------------
// Purpose/signature composition.
// ---------------------------------------------------------------------------

pub(super) fn namespace_level_types<'a>(root: Node<'a>) -> Vec<Node<'a>> {
    let mut types = Vec::new();
    collect_types(root, &mut types);
    types
}

fn collect_types<'a>(node: Node<'a>, types: &mut Vec<Node<'a>>) {
    for child in named_children(node) {
        if NAMESPACE_NODES.contains(&child.kind()) {
            let body = child.child_by_field_name("body").unwrap_or(child);
            collect_types(body, types);
            continue;
        }
        if type_kind_label(child.kind()).is_some() {
            types.push(child);
        }
    }
}

fn base_list_text(node: Node, src: &[u8]) -> String {
    let Some(bases) = named_children(node)
        .into_iter()
        .find(|c| c.kind() == "base_list")
    else {
        return String::new();
    };
    let raw = text(bases, src);
    let after_colon = match raw.strip_prefix(':') {
        Some(rest) => rest.trim_start(),
        None => raw.as_str(),
    };
    collapse_whitespace(after_colon).trim().to_string()
}

fn collapse_whitespace(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut in_ws = false;
    for ch in s.chars() {
        if ch.is_whitespace() {
            if !in_ws {
                out.push(' ');
                in_ws = true;
            }
        } else {
            out.push(ch);
            in_ws = false;
        }
    }
    out
}

pub(super) fn is_public(node: Node, src: &[u8]) -> bool {
    named_children(node)
        .into_iter()
        .any(|c| c.kind() == "modifier" && text(c, src) == "public")
}

pub(super) fn is_override(node: Node, src: &[u8]) -> bool {
    named_children(node)
        .into_iter()
        .any(|c| c.kind() == "modifier" && text(c, src) == "override")
}

// `IFace.Member` declarations: member lookup on the declaring type never
// finds them, only a receiver typed as the interface does.
pub(super) fn is_explicit_implementation(node: Node) -> bool {
    named_children(node)
        .into_iter()
        .any(|c| c.kind() == "explicit_interface_specifier")
}

fn strip_async_suffix(name: &str) -> String {
    name.strip_suffix("Async").unwrap_or(name).to_string()
}

// Purpose-signature method names: trailing "Async" stripped, deduped by the
// caller across the whole file. NOT the same helper as raw_method_names --
// a graph def needs the real, unabridged name (see that function's own
// comment).
fn public_method_names(node: Node, src: &[u8], kind: &str) -> Vec<String> {
    let Some(body) = node.child_by_field_name("body") else {
        return Vec::new();
    };
    named_children(body)
        .into_iter()
        .filter(|c| c.kind() == "method_declaration" && (kind == "interface" || is_public(*c, src)))
        .map(|c| strip_async_suffix(&declared_name(c, src)))
        .filter(|n| !n.is_empty())
        .collect()
}

// One namespace-level type's raw ingredients for a purpose segment --
// (line, kind, name, bases, unfiled public method names). Line comes along
// so `extract()`'s type-declaration-recovery path (below) can merge
// recovered types' parts into this list at the right sorted position
// before formatting; the ordinary (non-recovery) path never inspects it.
pub(super) type SegmentParts = (usize, &'static str, String, String, Vec<String>);

pub(super) fn type_segment_parts(node: Node, src: &[u8]) -> Option<SegmentParts> {
    let kind = type_kind_label(node.kind())?;
    let name = declared_name(node, src);
    if name.is_empty() {
        return None;
    }
    let bases = base_list_text(node, src);
    let methods = public_method_names(node, src, kind);
    Some((node.start_position().row + 1, kind, name, bases, methods))
}

// Formats an already line-ordered list of segment parts into one purpose
// string: "kind name[ : bases][; m1, m2]" joined with " | ", methods
// deduped globally across the whole list in order (a method name credited
// to an earlier type in the list is never repeated on a later one -- port
// of astPurposes' own cross-type dedup), then truncated to MAX_PURPOSE
// UTF-16 code units.
pub(super) fn format_segments(parts: Vec<SegmentParts>) -> Option<String> {
    let mut seen_methods: HashSet<String> = HashSet::new();
    let mut segments: Vec<String> = Vec::new();
    for (_line, kind, name, bases, raw_methods) in parts {
        let mut methods = Vec::new();
        for method in raw_methods {
            if seen_methods.contains(&method) {
                continue;
            }
            seen_methods.insert(method.clone());
            methods.push(method);
        }
        let header = if bases.is_empty() {
            format!("{kind} {name}")
        } else {
            format!("{kind} {name} : {bases}")
        };
        let segment = if methods.is_empty() {
            header
        } else {
            format!("{header}; {}", methods.join(", "))
        };
        segments.push(segment);
    }
    if segments.is_empty() {
        None
    } else {
        Some(truncate(&segments.join(" | ")))
    }
}

// NB: no `compose_signature(types, src)` wrapper -- extract()'s only
// caller needs to interleave a second (recovered) parts list before
// formatting, so it calls type_segment_parts()/format_segments() directly.

// The 200-unit limit counts UTF-16 code units, not bytes or chars -- matched
// here via `encode_utf16` rather than `str::len`/char indexing, so a purpose
// string with non-ASCII identifiers truncates at a code-unit boundary.
pub(super) fn truncate(s: &str) -> String {
    let units: Vec<u16> = s.encode_utf16().collect();
    if units.len() > MAX_PURPOSE {
        let mut truncated = String::from_utf16_lossy(&units[..MAX_PURPOSE - 3]);
        truncated.push_str("...");
        truncated
    } else {
        s.to_string()
    }
}