gobby-code 0.9.9

Fast Rust CLI for Gobby's code index — AST-aware search, symbol navigation, and dependency graph
Documentation
use crate::models::Symbol;

use super::resolution::CallSyntaxKind;
use super::text::{is_identifier_start, trim_identifier_token};

pub(super) fn external_call_is_shadowed(
    source: &[u8],
    caller_symbol: Option<&Symbol>,
    call_byte: usize,
    callee_name: &str,
    root_alias: Option<&str>,
    syntax: CallSyntaxKind,
) -> bool {
    let shadow_name = match syntax {
        CallSyntaxKind::Bare => Some(callee_name),
        CallSyntaxKind::Member => root_alias,
        CallSyntaxKind::Other => None,
    };
    let Some(shadow_name) = shadow_name.filter(|name| !name.is_empty()) else {
        return false;
    };
    local_name_in_scope_before_call(source, caller_symbol, call_byte, shadow_name)
}

fn local_name_in_scope_before_call(
    source: &[u8],
    caller_symbol: Option<&Symbol>,
    call_byte: usize,
    name: &str,
) -> bool {
    let start = caller_symbol.map(|symbol| symbol.byte_start).unwrap_or(0);
    if start >= source.len() || start >= call_byte {
        return false;
    }
    let end = call_byte.min(source.len());
    let prefix = String::from_utf8_lossy(&source[start..end]);
    let prefix_without_block_comments = remove_block_comments(&prefix);
    caller_symbol
        .is_some_and(|_| parameter_list_contains_name(&prefix_without_block_comments, name))
        || prefix_without_block_comments
            .lines()
            .any(|line| local_binding_line_defines(line, name))
}

fn remove_block_comments(text: &str) -> String {
    let mut cleaned = String::with_capacity(text.len());
    let mut cursor = 0;
    while let Some(relative_start) = text[cursor..].find("/*") {
        let start = cursor + relative_start;
        cleaned.push_str(&text[cursor..start]);

        let mut depth = 1usize;
        let mut scan = start + 2;
        while scan < text.len() {
            let rest = &text[scan..];
            let next_open = rest.find("/*").map(|index| scan + index);
            let next_close = rest.find("*/").map(|index| scan + index);
            match (next_open, next_close) {
                (Some(open), Some(close)) if open < close => {
                    depth += 1;
                    scan = open + 2;
                }
                (_, Some(close)) => {
                    depth -= 1;
                    scan = close + 2;
                    if depth == 0 {
                        break;
                    }
                }
                _ => {
                    scan = text.len();
                    break;
                }
            }
        }

        if depth > 0 {
            return cleaned;
        }
        cursor = scan;
    }
    cleaned.push_str(&text[cursor..]);
    cleaned
}

fn parameter_list_contains_name(prefix: &str, name: &str) -> bool {
    let Some(open) = prefix.find('(') else {
        return false;
    };
    let Some(close) = matching_paren_in_str(prefix, open) else {
        return false;
    };
    prefix[open + 1..close]
        .split(',')
        .any(|param| parameter_segment_name(param).is_some_and(|param_name| param_name == name))
}

fn matching_paren_in_str(text: &str, open: usize) -> Option<usize> {
    let mut depth = 0usize;
    for (idx, ch) in text.char_indices().skip_while(|(idx, _)| *idx < open) {
        match ch {
            '(' => depth += 1,
            ')' => {
                depth = depth.saturating_sub(1);
                if depth == 0 {
                    return Some(idx);
                }
            }
            _ => {}
        }
    }
    None
}

fn parameter_segment_name(segment: &str) -> Option<&str> {
    let segment = segment
        .split('=')
        .next()
        .unwrap_or(segment)
        .split(':')
        .next()
        .unwrap_or(segment)
        .trim();
    segment
        .split_whitespace()
        .find(|token| token.chars().next().is_some_and(is_identifier_start))
        .map(trim_identifier_token)
        .filter(|token| !token.is_empty())
}

fn local_binding_line_defines(line: &str, name: &str) -> bool {
    let line = line.trim_start();
    if line.is_empty()
        || line.starts_with("//")
        || line.starts_with('#')
        || line.starts_with("import ")
        || line.starts_with("from ")
        || line.starts_with("use ")
    {
        return false;
    }
    if let Some((left, _)) = line.split_once(":=")
        && binding_left_side_contains(left, name)
    {
        return true;
    }
    if let Some((left, _)) = split_assignment(line)
        && binding_left_side_contains(left, name)
    {
        return true;
    }
    declaration_without_assignment_contains(line, name)
}

fn split_assignment(line: &str) -> Option<(&str, &str)> {
    let mut in_single = false;
    let mut in_double = false;
    let mut escaped = false;
    let mut chars = line.char_indices().peekable();

    while let Some((idx, ch)) = chars.next() {
        if escaped {
            escaped = false;
            continue;
        }
        if (in_single || in_double) && ch == '\\' {
            escaped = true;
            continue;
        }
        if !in_single && !in_double && ch == '/' && chars.peek().is_some_and(|(_, ch)| *ch == '/') {
            break;
        }
        if !in_single && !in_double && ch == '#' {
            break;
        }
        match ch {
            '\'' if !in_double => {
                in_single = !in_single;
                continue;
            }
            '"' if !in_single => {
                in_double = !in_double;
                continue;
            }
            _ => {}
        }
        if in_single || in_double || ch != '=' {
            continue;
        }
        let previous = line[..idx].chars().next_back();
        let next = line[idx + 1..].chars().next();
        if matches!(
            previous,
            Some(
                '=' | '!'
                    | '<'
                    | '>'
                    | ':'
                    | '+'
                    | '-'
                    | '*'
                    | '/'
                    | '%'
                    | '&'
                    | '|'
                    | '^'
                    | '?'
                    | '@'
                    | '~'
            )
        ) || matches!(next, Some('=' | '>'))
        {
            continue;
        }
        return Some((&line[..idx], &line[idx + 1..]));
    }
    None
}

fn binding_left_side_contains(left: &str, name: &str) -> bool {
    left.split(',')
        .filter_map(|part| binding_name_from_left_part(part))
        .any(|binding_name| binding_name == name)
}

fn binding_name_from_left_part(part: &str) -> Option<&str> {
    let part = part.split(':').next().unwrap_or(part).trim();
    if part.contains(['.', '[', ']']) {
        return None;
    }
    part.split_whitespace()
        .next_back()
        .map(trim_identifier_token)
        .filter(|token| !token.is_empty())
}

fn declaration_without_assignment_contains(line: &str, name: &str) -> bool {
    if let Some(rest) = line
        .strip_prefix("let ")
        .or_else(|| line.strip_prefix("const "))
        .or_else(|| line.strip_prefix("var "))
        .or_else(|| line.strip_prefix("val "))
    {
        return rest
            .split([',', ';'])
            .filter_map(binding_name_from_name_first_part)
            .any(|binding_name| binding_name == name);
    }

    let Some(rest) = line
        .strip_prefix("final ")
        .or_else(|| line.strip_prefix("late "))
        .or_else(|| line.strip_prefix("auto "))
    else {
        return false;
    };
    rest.split([',', ';'])
        .filter_map(binding_name_from_left_part)
        .any(|binding_name| binding_name == name)
}

fn binding_name_from_name_first_part(part: &str) -> Option<&str> {
    let part = part.trim();
    let token = part.split_whitespace().next()?;
    if token.contains(['.', '[', ']']) {
        return None;
    }
    token
        .split(':')
        .next()
        .map(trim_identifier_token)
        .filter(|token| !token.is_empty())
}

#[cfg(test)]
mod tests {
    use super::{
        declaration_without_assignment_contains, local_name_in_scope_before_call,
        remove_block_comments, split_assignment,
    };

    #[test]
    fn split_assignment_ignores_bitwise_compound_operators() {
        assert_eq!(split_assignment("flags &= READ"), None);
        assert_eq!(split_assignment("flags |= WRITE"), None);
        assert_eq!(split_assignment("flags ^= EXECUTE"), None);
        assert_eq!(split_assignment("value += 1"), None);
        assert_eq!(split_assignment("matrix @= transform"), None);
        assert_eq!(split_assignment("cache ??= fallback"), None);
        assert_eq!(split_assignment("enabled &&= predicate()"), None);
        assert_eq!(split_assignment("enabled ||= fallback()"), None);
        assert_eq!(split_assignment("power **= 2"), None);
        assert_eq!(split_assignment("bits <<= 1"), None);
        assert_eq!(split_assignment("bits >>= 1"), None);
        assert_eq!(
            split_assignment("flags = READ | WRITE"),
            Some(("flags ", " READ | WRITE"))
        );
    }

    #[test]
    fn name_first_declarations_use_declared_name() {
        assert!(declaration_without_assignment_contains(
            "var client http.Client",
            "client"
        ));
        assert!(declaration_without_assignment_contains(
            "val owner: User",
            "owner"
        ));
        assert!(!declaration_without_assignment_contains(
            "var client http.Client",
            "Client"
        ));
    }

    #[test]
    fn typed_assignment_bindings_use_name_before_colon() {
        assert!(super::binding_left_side_contains(
            "let owner: User",
            "owner"
        ));
        assert!(!super::binding_left_side_contains(
            "let owner: User",
            "User"
        ));
    }

    #[test]
    fn block_comments_do_not_define_shadowing_bindings() {
        let prefix = "/*\nconst fetch = localFetch;\n*/\nfetch();";
        assert_eq!(remove_block_comments(prefix), "\nfetch();");
        assert!(!local_name_in_scope_before_call(
            prefix.as_bytes(),
            None,
            prefix.len(),
            "fetch"
        ));
    }

    #[test]
    fn nested_block_comments_do_not_leak_inner_tail() {
        let prefix = "/* outer /* inner */ const fetch = localFetch; */\nfetch();";
        assert_eq!(remove_block_comments(prefix), "\nfetch();");
        assert!(!local_name_in_scope_before_call(
            prefix.as_bytes(),
            None,
            prefix.len(),
            "fetch"
        ));
    }

    #[test]
    fn unclosed_block_comments_are_treated_as_eof_terminated() {
        let prefix = "let keep = 1;\n/* const fetch = localFetch;\nfetch();";
        assert_eq!(remove_block_comments(prefix), "let keep = 1;\n");
        assert!(!local_name_in_scope_before_call(
            prefix.as_bytes(),
            None,
            prefix.len(),
            "fetch"
        ));
    }
}