gobby-code 1.3.2

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

use super::super::context::{ExtractedImports, ImportResolutionContext, LocalCallBinding};
use super::super::helpers::{collapse_whitespace, extract_quoted_string};

pub(crate) fn parse_lua_import_statement(
    text: &str,
    rel_path: &str,
    import_context: &ImportResolutionContext,
    extracted: &mut ExtractedImports,
) {
    let normalized = collapse_whitespace(text);
    if !normalized.contains("require") {
        return;
    }
    let Some(module) = extract_quoted_string(&normalized) else {
        return;
    };

    extracted.imports.push(ImportRelation {
        file_path: rel_path.to_string(),
        module_name: module.clone(),
    });

    let candidate_files = import_context.lua_module_files(&module);
    if candidate_files.is_empty() {
        return;
    }

    let Some((alias, member)) = lua_require_assignment(&normalized) else {
        return;
    };
    if let Some(member) = member {
        extracted
            .bindings
            .local_bare
            .insert(alias, LocalCallBinding::named(candidate_files, member));
    } else {
        extracted
            .bindings
            .local_member
            .insert(alias, candidate_files);
    }
}

pub(crate) fn resolve_lua_require_member_callee(
    import_context: &ImportResolutionContext,
    callee_name: &str,
    qualifier_path: Option<&str>,
    is_member_call: bool,
) -> Option<LocalCallBinding> {
    if !is_member_call {
        return None;
    }
    let qualifier_path = qualifier_path?.trim();
    if !qualifier_path.starts_with("require") {
        return None;
    }
    let module = extract_quoted_string(qualifier_path)?;
    let candidate_files = import_context.lua_module_files(&module);
    if candidate_files.is_empty() {
        return None;
    }
    Some(LocalCallBinding::named(
        candidate_files,
        callee_name.to_string(),
    ))
}

fn lua_require_assignment(text: &str) -> Option<(String, Option<String>)> {
    let (lhs, rhs) = text.split_once('=')?;
    let lhs = lhs
        .trim()
        .strip_prefix("local ")
        .unwrap_or(lhs.trim())
        .trim();
    if lhs.contains(',') || !is_lua_identifier(lhs) {
        return None;
    }

    let require_pos = rhs.find("require")?;
    let after_require = &rhs[require_pos + "require".len()..];
    let after_literal = after_first_quoted_string(after_require)?;
    Some((lhs.to_string(), lua_member_after_require(after_literal)))
}

fn after_first_quoted_string(text: &str) -> Option<&str> {
    let quote = text.find(['"', '\''])?;
    let quote_char = text[quote..].chars().next()?;
    let after_quote = &text[quote + quote_char.len_utf8()..];
    let mut escaped = false;
    let mut idx = 0;
    while idx < after_quote.len() {
        let ch = after_quote[idx..].chars().next()?;
        if escaped {
            escaped = false;
            idx += ch.len_utf8();
            continue;
        }
        if ch == '\\' {
            escaped = true;
            idx += ch.len_utf8();
            continue;
        }
        if ch == quote_char {
            return Some(&after_quote[idx + ch.len_utf8()..]);
        }
        idx += ch.len_utf8();
    }
    None
}

fn lua_member_after_require(text: &str) -> Option<String> {
    let mut rest = text.trim_start();
    if let Some(after_paren) = rest.strip_prefix(')') {
        rest = after_paren.trim_start();
    }
    let rest = rest.strip_prefix(['.', ':'])?;
    let member = rest
        .chars()
        .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_')
        .collect::<String>();
    if is_lua_identifier(&member) {
        Some(member)
    } else {
        None
    }
}

fn is_lua_identifier(name: &str) -> bool {
    let mut chars = name.chars();
    let Some(first) = chars.next() else {
        return false;
    };
    (first.is_ascii_alphabetic() || first == '_')
        && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
}