"""Language detection, symbol extraction and import resolution.

Python goes through ``ast``. Every other language is matched with tuned regexes
against a *blanked* copy of the source, where string literals and comments have
been replaced by spaces of equal length. Blanking removes the usual regex
failure mode (declarations quoted inside strings or commented out) and the same
pass yields brace depth per line and the closing line of every block, which is
where the line ranges come from.
"""

from __future__ import annotations

import ast
import posixpath
import re

LANGUAGES = {
    ".py": "python",
    ".pyi": "python",
    ".ts": "typescript",
    ".tsx": "typescript",
    ".mts": "typescript",
    ".cts": "typescript",
    ".js": "javascript",
    ".jsx": "javascript",
    ".mjs": "javascript",
    ".cjs": "javascript",
    ".go": "go",
    ".rs": "rust",
    ".java": "java",
    ".rb": "ruby",
    ".php": "php",
    ".cs": "csharp",
    ".swift": "swift",
    ".kt": "kotlin",
    ".kts": "kotlin",
    ".scala": "scala",
    ".sc": "scala",
    ".c": "c",
    ".h": "c",
    ".cc": "cpp",
    ".cpp": "cpp",
    ".hpp": "cpp",
    ".m": "objc",
    ".sh": "shell",
    ".bash": "shell",
    ".sql": "sql",
    ".css": "css",
    ".scss": "css",
    ".html": "html",
    ".md": "markdown",
    ".mdx": "markdown",
    ".rst": "markdown",
    ".txt": "text",
    ".json": "json",
    ".yml": "yaml",
    ".yaml": "yaml",
    ".toml": "toml",
    ".ini": "ini",
    ".cfg": "ini",
    ".proto": "proto",
    ".graphql": "graphql",
}

_C_LIKE = frozenset(
    {
        "typescript",
        "javascript",
        "go",
        "rust",
        "java",
        "csharp",
        "swift",
        "kotlin",
        "scala",
        "php",
        "c",
        "cpp",
        "objc",
    }
)
_HASH_COMMENT = frozenset({"php", "ruby", "shell", "toml", "yaml", "ini"})
_TEMPLATE_STRING = frozenset({"typescript", "javascript"})
_RAW_BACKTICK = frozenset({"go"})


def language_for(rel_path: str) -> str | None:
    name = posixpath.basename(rel_path)
    if name.endswith(".d.ts"):
        return "typescript"
    dot = name.rfind(".")
    if dot <= 0:
        return None
    return LANGUAGES.get(name[dot:].lower())


# --------------------------------------------------------------------------- #
# blanking pass
# --------------------------------------------------------------------------- #


_STRING_TAIL = {
    quote: re.compile(r"(?:[^" + quote + r"\\]|\\.)*(" + quote + r"|$)") for quote in ("'", '"')
}
_TEMPLATE_NEXT = re.compile(r"\\.|\$\{|`")
_INTERESTING: dict[str, re.Pattern[str]] = {}

# Languages with /.../ regex literals. A backtick or quote inside one must not
# open a string state: /`[^`]+`/ would otherwise flip the template state and
# blank the rest of the file.
_REGEX_LITERAL = frozenset({"typescript", "javascript"})
_REGEX_PRECEDER_WORDS = frozenset(
    {"return", "case", "typeof", "instanceof", "in", "of", "delete", "void", "throw", "new", "do", "else", "yield", "await"}
)
_REGEX_PRECEDER_TAIL = re.compile(r"([A-Za-z_$][\w$]*)\s*$")


def _interesting(language: str) -> re.Pattern[str]:
    cached = _INTERESTING.get(language)
    if cached is not None:
        return cached
    parts = [r"//", r"/\*"] if language in _C_LIKE else []
    if language in _REGEX_LITERAL:
        parts.append("/")
    if language in _HASH_COMMENT:
        parts.append("#")
    if language in _TEMPLATE_STRING or language in _RAW_BACKTICK:
        parts.append("`")
    parts += ['"', "'", r"\{", r"\}"]
    pattern = re.compile("|".join(parts))
    _INTERESTING[language] = pattern
    return pattern


def _regex_can_start(prefix: str) -> bool:
    """Judge from the significant tail of the blanked prefix whether ``/`` opens a regex.

    Division follows a value (identifier, number, ``)``, ``]``); a regex follows
    an operator, an opener, or a keyword. When nothing decides, prefer regex:
    misreading division blanks a few characters, misreading a regex leaves its
    quotes and backticks live to poison the rest of the file.
    """
    stripped = prefix.rstrip()
    if not stripped:
        return True
    last = stripped[-1]
    if last in "([{,=:;!&|?+-*%~^<>":
        return True
    match = _REGEX_PRECEDER_TAIL.search(stripped)
    if match and match.group(1) in _REGEX_PRECEDER_WORDS:
        return True
    return False


def _regex_literal_end(line: str, start: int) -> int | None:
    """End index (exclusive, flags included) of a regex literal opening at ``line[start]``.

    Regex literals never span lines, so no closing ``/`` means the slash was
    division after all.
    """
    index = start + 1
    in_class = False
    size = len(line)
    while index < size:
        ch = line[index]
        if ch == "\\":
            index += 2
            continue
        if in_class:
            if ch == "]":
                in_class = False
        elif ch == "[":
            in_class = True
        elif ch == "/":
            index += 1
            while index < size and line[index].isalpha():
                index += 1
            return index
        index += 1
    return None


def _blanked_prefix(line: str, index: int, spans: list[tuple[int, int]]) -> str:
    if not spans:
        return line[:index]
    buffer = list(line[:index])
    for start, stop in spans:
        clipped = min(stop, index)
        if start < clipped:
            buffer[start:clipped] = " " * (clipped - start)
    return "".join(buffer)


def _is_rust_char_literal(line: str, index: int) -> bool:
    if index + 1 < len(line) and line[index + 1] == "\\":
        return True
    return index + 2 < len(line) and line[index + 2] == "'"


def scan_source(text: str, language: str) -> tuple[list[str], list[str], list[int], dict[int, int]]:
    """Return (raw lines, blanked lines, brace depth per line, open line -> close line).

    The blanked copy has every string literal and comment replaced by spaces of
    the same width, so line numbers and columns still line up.
    """
    lines = text.split("\n")
    blanked: list[str] = []
    depth: list[int] = []
    close: dict[int, int] = {}
    open_stack: list[int] = []
    template_depths: list[int] = []
    state = "code"

    interesting = _interesting(language)
    templates = language in _TEMPLATE_STRING
    raw_backtick = language in _RAW_BACKTICK
    rust = language == "rust"

    spans: list[tuple[int, int]] = []

    def blank(start: int, stop: int) -> None:
        spans.append((start, stop))

    for row, line in enumerate(lines):
        depth.append(len(open_stack))
        size = len(line)
        spans.clear()
        index = 0

        while index < size:
            if state == "comment":
                found = line.find("*/", index)
                stop = size if found < 0 else found + 2
                blank(index, stop)
                index = stop
                if found >= 0:
                    state = "code"
                continue
            if state == "raw":
                found = line.find("`", index)
                stop = size if found < 0 else found + 1
                blank(index, stop)
                index = stop
                if found >= 0:
                    state = "code"
                continue
            if state == "template":
                match = _TEMPLATE_NEXT.search(line, index)
                if match is None:
                    blank(index, size)
                    index = size
                    continue
                token = match.group()
                blank(index, match.end())
                index = match.end()
                if token == "`":
                    state = "code"
                elif token == "${":
                    template_depths.append(len(open_stack))
                    state = "code"
                continue

            match = interesting.search(line, index)
            if match is None:
                break
            index = match.start()
            token = match.group()
            if token == "{":
                open_stack.append(row)
                index += 1
                continue
            if token == "}":
                if template_depths and len(open_stack) == template_depths[-1]:
                    template_depths.pop()
                    state = "template"
                    blank(index, index + 1)
                    index += 1
                    continue
                if open_stack:
                    opened = open_stack.pop()
                    if close.get(opened, -1) < row:
                        close[opened] = row
                index += 1
                continue
            if token in ("//", "#"):
                blank(index, size)
                index = size
                continue
            if token == "/*":
                blank(index, min(index + 2, size))
                index += 2
                state = "comment"
                continue
            if token == "/":
                prefix = _blanked_prefix(line, index, spans)
                if not prefix.strip() and blanked:
                    # Nothing significant on this line yet: judge from the
                    # previous line, the continuation case for call arguments.
                    prefix = blanked[-1]
                if not _regex_can_start(prefix):
                    index += 1
                    continue
                stop = _regex_literal_end(line, index)
                if stop is None:
                    index += 1
                    continue
                blank(index, stop)
                index = stop
                continue
            if token == "`":
                blank(index, index + 1)
                index += 1
                state = "template" if templates else "raw"
                continue
            if token == "'" and rust and not _is_rust_char_literal(line, index):
                index += 1
                continue
            tail = _STRING_TAIL[token].match(line, index + 1)
            stop = tail.end() if tail else size
            blank(index, stop)
            index = max(stop, index + 1)

        if spans:
            buffer = list(line)
            for start, stop in spans:
                buffer[start:stop] = " " * (stop - start)
            blanked.append("".join(buffer))
        else:
            blanked.append(line)
    return lines, blanked, depth, close


def block_end(blanked: list[str], close: dict[int, int], row: int) -> int:
    """Last line of the declaration that starts on ``row``."""
    if row in close:
        return close[row]
    line = blanked[row].rstrip()
    stripped = line.strip()
    if stripped.endswith(";"):
        return row
    total = len(blanked)
    if row + 1 < total and blanked[row + 1].lstrip().startswith("{"):
        return close.get(row + 1, row + 1)
    balance = line.count("(") - line.count(")")
    cursor = row
    while balance > 0 and cursor + 1 < total and cursor - row < 8:
        cursor += 1
        if cursor in close:
            return close[cursor]
        balance += blanked[cursor].count("(") - blanked[cursor].count(")")
        if blanked[cursor].strip().endswith(";"):
            return cursor
    if cursor > row and cursor + 1 < total and blanked[cursor + 1].lstrip().startswith("{"):
        return close.get(cursor + 1, cursor + 1)
    return cursor


# --------------------------------------------------------------------------- #
# python
# --------------------------------------------------------------------------- #

_CONST_NAME = re.compile(r"^[A-Z][A-Z0-9_]*$")


def _python_start(node: ast.AST) -> int:
    lines = [getattr(node, "lineno", 1)]
    for decorator in getattr(node, "decorator_list", []) or []:
        lines.append(decorator.lineno)
    return min(lines)


def _python_symbols(tree: ast.Module) -> list[dict]:
    found: list[dict] = []

    def visit(node: ast.AST, prefix: str) -> None:
        for child in getattr(node, "body", []) or []:
            if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
                found.append(
                    {
                        "name": prefix + child.name,
                        "kind": "method" if prefix else "function",
                        "line": _python_start(child),
                        "end_line": child.end_lineno or child.lineno,
                    }
                )
            elif isinstance(child, ast.ClassDef):
                name = prefix + child.name
                found.append(
                    {
                        "name": name,
                        "kind": "class",
                        "line": _python_start(child),
                        "end_line": child.end_lineno or child.lineno,
                    }
                )
                visit(child, name + ".")
            elif not prefix and isinstance(child, ast.Assign):
                for target in child.targets:
                    if isinstance(target, ast.Name) and _CONST_NAME.match(target.id):
                        found.append(
                            {
                                "name": target.id,
                                "kind": "const",
                                "line": child.lineno,
                                "end_line": child.end_lineno or child.lineno,
                            }
                        )
            elif not prefix and isinstance(child, ast.AnnAssign):
                target = child.target
                if isinstance(target, ast.Name) and _CONST_NAME.match(target.id):
                    found.append(
                        {
                            "name": target.id,
                            "kind": "const",
                            "line": child.lineno,
                            "end_line": child.end_lineno or child.lineno,
                        }
                    )

    visit(tree, "")
    return found


def _python_imports(tree: ast.Module) -> list[str]:
    specs: list[str] = []
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            specs.extend(alias.name for alias in node.names)
        elif isinstance(node, ast.ImportFrom):
            base = "." * node.level + (node.module or "")
            if base:
                specs.append(base)
    return specs


# --------------------------------------------------------------------------- #
# regex language table
# --------------------------------------------------------------------------- #

_ACCESS = r"(?:pub(?:\s*\([^)]*\))?\s+)?"

_LANGUAGE_RULES: dict[str, dict[str, tuple]] = {
    "typescript": {
        "container": (
            (
                re.compile(
                    r"^\s*(?:export\s+)?(?:default\s+)?(?:declare\s+)?(?:abstract\s+)?"
                    r"class\s+(?P<name>[A-Za-z_$][\w$]*)"
                ),
                "class",
            ),
            (
                re.compile(r"^\s*(?:export\s+)?(?:declare\s+)?interface\s+(?P<name>[A-Za-z_$][\w$]*)"),
                "interface",
            ),
            (
                re.compile(r"^\s*(?:export\s+)?(?:declare\s+)?(?:const\s+)?enum\s+(?P<name>[A-Za-z_$][\w$]*)"),
                "enum",
            ),
        ),
        "top": (
            (
                re.compile(
                    r"^\s*(?:export\s+)?(?:default\s+)?(?:declare\s+)?(?:async\s+)?"
                    r"function\s*\*?\s*(?P<name>[A-Za-z_$][\w$]*)"
                ),
                "function",
            ),
            (
                re.compile(r"^\s*(?:export\s+)?(?:declare\s+)?type\s+(?P<name>[A-Za-z_$][\w$]*)\s*(?:<.*>)?\s*="),
                "type",
            ),
            (
                re.compile(r"^\s*export\s+(?:declare\s+)?(?:const|let|var)\s+(?P<name>[A-Za-z_$][\w$]*)"),
                "const",
            ),
            (
                # Module-private constants: SCREAMING_CASE only, so ordinary
                # locals never pollute the symbol table (depth 0 is enforced
                # by the caller, this rule just needs the shape).
                re.compile(r"^(?:const|let|var)\s+(?P<name>[A-Z][A-Z0-9_]{2,})\s*(?::[^=]+)?="),
                "const",
            ),
            (
                re.compile(
                    r"^(?:const|let|var)\s+(?P<name>[A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*"
                    r"(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*(?::[^=]+)?=>"
                ),
                "function",
            ),
        ),
        "member": (
            (
                re.compile(
                    r"^[ \t]+(?:(?:public|private|protected|static|readonly|abstract|override|async|declare)\s+)*"
                    r"(?:(?:get|set)\s+)?\*?\s*(?P<name>[A-Za-z_$][\w$]*)\s*(?:<[^<>()]*>)?\s*\("
                ),
                "method",
            ),
            (
                re.compile(
                    r"^[ \t]+(?:(?:public|private|protected|static|readonly|override|declare)\s+)*"
                    r"(?P<name>[A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*(?:async\s*)?\([^)]*\)\s*(?::[^=]+)?=>"
                ),
                "method",
            ),
        ),
        "reserved": frozenset(
            {
                "if",
                "for",
                "while",
                "switch",
                "catch",
                "return",
                "do",
                "else",
                "try",
                "new",
                "typeof",
                "delete",
                "void",
                "await",
                "yield",
                "function",
                "import",
                "export",
                "super",
                "this",
                "case",
                "with",
                "in",
                "of",
            }
        ),
    },
    "go": {
        "container": (
            (re.compile(r"^type\s+(?P<name>\w+)\s+struct\b"), "struct"),
            (re.compile(r"^type\s+(?P<name>\w+)\s+interface\b"), "interface"),
        ),
        "top": (
            (
                re.compile(r"^func\s+\(\s*\w*\s*\**\s*(?P<owner>\w+)\s*\)\s*(?P<name>\w+)\s*\("),
                "method",
            ),
            (re.compile(r"^func\s+(?P<name>\w+)\s*(?:\[[^\]]*\])?\s*\("), "function"),
            (re.compile(r"^type\s+(?P<name>\w+)\s+\S"), "type"),
            (re.compile(r"^(?:const|var)\s+(?P<name>[A-Za-z_]\w*)\s"), "const"),
        ),
        "member": (),
        "reserved": frozenset(),
    },
    "rust": {
        "container": (
            (re.compile(r"^\s*" + _ACCESS + r"struct\s+(?P<name>\w+)"), "struct"),
            (re.compile(r"^\s*" + _ACCESS + r"enum\s+(?P<name>\w+)"), "enum"),
            (re.compile(r"^\s*" + _ACCESS + r"(?:unsafe\s+)?trait\s+(?P<name>\w+)"), "trait"),
            (
                re.compile(
                    r"^\s*(?:unsafe\s+)?impl(?:\s*<[^>]*>)?\s+(?:[\w:]+(?:\s*<[^>]*>)?\s+for\s+)?"
                    r"(?P<name>[\w:]+)"
                ),
                "impl",
            ),
            (re.compile(r"^\s*" + _ACCESS + r"mod\s+(?P<name>\w+)\s*\{"), "module"),
        ),
        "top": (
            (
                re.compile(
                    r"^\s*" + _ACCESS + r"(?:async\s+)?(?:unsafe\s+)?(?:extern\s+\S*\s*)?"
                    r"fn\s+(?P<name>\w+)"
                ),
                "function",
            ),
            (re.compile(r"^\s*" + _ACCESS + r"(?:static|const)\s+(?P<name>[A-Z_][A-Z0-9_]*)"), "const"),
        ),
        "member": (
            (
                re.compile(
                    r"^\s*" + _ACCESS + r"(?:async\s+)?(?:unsafe\s+)?(?:extern\s+\S*\s*)?"
                    r"fn\s+(?P<name>\w+)"
                ),
                "method",
            ),
        ),
        "reserved": frozenset(),
    },
    "java": {
        "container": (
            (
                re.compile(
                    r"^\s*(?:(?:public|private|protected|static|final|abstract|sealed|non-sealed)\s+)*"
                    r"(?:class|interface|enum|record)\s+(?P<name>\w+)"
                ),
                "class",
            ),
        ),
        "top": (),
        "member": (
            (
                re.compile(
                    r"^[ \t]+(?:(?:public|private|protected|static|final|abstract|synchronized|native|"
                    r"strictfp|default)\s+)*(?:<[^<>]+>\s+)?[\w$][\w$<>\[\],.?\s]*\s+(?P<name>\w+)\s*\("
                ),
                "method",
            ),
            (re.compile(r"^[ \t]+(?:public|private|protected)\s+(?P<name>[A-Z]\w*)\s*\("), "method"),
        ),
        "reserved": frozenset({"if", "for", "while", "switch", "catch", "return", "new", "do", "else", "try", "synchronized"}),
    },
    "csharp": {
        "container": (
            (
                re.compile(
                    r"^\s*(?:(?:public|private|protected|internal|static|abstract|sealed|partial|readonly|record)\s+)*"
                    r"(?:class|interface|enum|struct|record)\s+(?P<name>\w+)"
                ),
                "class",
            ),
        ),
        "top": (),
        "member": (
            (
                re.compile(
                    r"^[ \t]+(?:(?:public|private|protected|internal|static|virtual|override|abstract|sealed|async|"
                    r"unsafe|extern|partial|new)\s+)*[\w$][\w<>\[\],.?\s]*\s+(?P<name>\w+)\s*\("
                ),
                "method",
            ),
            (re.compile(r"^[ \t]+(?:public|private|protected|internal)\s+(?P<name>[A-Z]\w*)\s*\("), "method"),
        ),
        "reserved": frozenset({"if", "for", "foreach", "while", "switch", "catch", "return", "new", "do", "else", "try", "using", "lock"}),
    },
    "swift": {
        "container": (
            (
                re.compile(
                    r"^\s*(?:(?:public|private|internal|fileprivate|open|final|@\w+)\s+)*"
                    r"(?:class|struct|enum|protocol|extension|actor)\s+(?P<name>\w+)"
                ),
                "class",
            ),
        ),
        "top": (
            (re.compile(r"^\s*(?:(?:public|private|internal|fileprivate|open|static|final|@\w+)\s+)*func\s+(?P<name>\w+)"), "function"),
            (re.compile(r"^(?:(?:public|private|internal|open)\s+)?(?:let|var)\s+(?P<name>\w+)"), "const"),
        ),
        "member": (
            (
                re.compile(
                    r"^\s*(?:(?:public|private|internal|fileprivate|open|static|final|class|override|mutating|@\w+)\s+)*"
                    r"func\s+(?P<name>\w+)"
                ),
                "method",
            ),
        ),
        "reserved": frozenset(),
    },
    "kotlin": {
        "container": (
            (
                re.compile(
                    r"^\s*(?:(?:public|private|internal|protected|open|abstract|sealed|data|inner|value)\s+)*"
                    r"(?:class|object|interface)\s+(?P<name>\w+)"
                ),
                "class",
            ),
        ),
        "top": (
            (
                re.compile(
                    r"^\s*(?:(?:public|private|internal|protected|inline|suspend|external|operator|tailrec)\s+)*"
                    r"fun\s+(?:<[^>]*>\s*)?(?P<name>\w+)"
                ),
                "function",
            ),
            (re.compile(r"^(?:(?:public|private|internal|const)\s+)*(?:val|var)\s+(?P<name>\w+)"), "const"),
        ),
        "member": (
            (
                re.compile(
                    r"^\s*(?:(?:public|private|internal|protected|override|open|abstract|inline|suspend|operator)\s+)*"
                    r"fun\s+(?:<[^>]*>\s*)?(?P<name>\w+)"
                ),
                "method",
            ),
        ),
        "reserved": frozenset(),
    },
    "scala": {
        "container": (
            (
                re.compile(r"^\s*(?:(?:private|protected|final|sealed|abstract|case|implicit)\s+)*(?:class|object|trait)\s+(?P<name>\w+)"),
                "class",
            ),
        ),
        "top": (
            (re.compile(r"^\s*(?:(?:private|protected|final|override|implicit)\s+)*def\s+(?P<name>[\w$]+)"), "function"),
            (re.compile(r"^\s*(?:(?:private|protected|final|implicit|lazy)\s+)*val\s+(?P<name>\w+)"), "const"),
        ),
        "member": (
            (re.compile(r"^\s*(?:(?:private|protected|final|override|implicit)\s+)*def\s+(?P<name>[\w$]+)"), "method"),
        ),
        "reserved": frozenset(),
    },
    "php": {
        "container": (
            (
                re.compile(r"^\s*(?:(?:final|abstract|readonly)\s+)*(?:class|interface|trait|enum)\s+(?P<name>\w+)"),
                "class",
            ),
        ),
        "top": (
            (re.compile(r"^\s*function\s+&?(?P<name>\w+)\s*\("), "function"),
            (re.compile(r"^\s*(?:const|define)\s*\(?\s*[\"']?(?P<name>[A-Z_][A-Z0-9_]*)"), "const"),
        ),
        "member": (
            (
                re.compile(
                    r"^\s*(?:(?:public|private|protected|static|final|abstract)\s+)*function\s+&?(?P<name>\w+)\s*\("
                ),
                "method",
            ),
        ),
        "reserved": frozenset(),
    },
}
_LANGUAGE_RULES["javascript"] = _LANGUAGE_RULES["typescript"]


def _symbol_name(match: re.Match[str]) -> str | None:
    groups = match.groupdict()
    if groups:
        owner = groups.get("owner")
        name = groups.get("name")
        if owner and name:
            return f"{owner}.{name}"
        if name:
            return name
        if owner:
            return owner
    for value in reversed(match.groups()):
        if value:
            return value
    return None


def _match_rules(rules: tuple, line: str, reserved: frozenset) -> tuple[str, str] | None:
    for pattern, kind in rules:
        match = pattern.match(line)
        if not match:
            continue
        name = _symbol_name(match)
        if not name or name in reserved:
            continue
        return name, kind
    return None


def _regex_symbols(text: str, language: str) -> list[dict]:
    rules = _LANGUAGE_RULES.get(language)
    if rules is None:
        return []
    _, blanked, depth, close = scan_source(text, language)
    reserved = rules["reserved"]

    containers: list[dict] = []
    for row, line in enumerate(blanked):
        if not line.strip():
            continue
        hit = _match_rules(rules["container"], line, reserved)
        if hit is None:
            continue
        name, kind = hit
        containers.append(
            {"name": name, "kind": kind, "line": row + 1, "end_line": block_end(blanked, close, row) + 1, "depth": depth[row], "row": row}
        )

    found = [
        {"name": item["name"], "kind": item["kind"], "line": item["line"], "end_line": item["end_line"]}
        for item in containers
    ]
    starts: dict[int, list[dict]] = {}
    for item in containers:
        starts.setdefault(item["row"], []).append(item)

    active: list[dict] = []
    for row, line in enumerate(blanked):
        while active and active[-1]["end_line"] - 1 < row:
            active.pop()
        if row in starts:
            active.extend(starts[row])
            continue
        if not line.strip():
            continue
        owner = active[-1] if active else None
        if owner is None:
            if depth[row] != 0:
                continue
            hit = _match_rules(rules["top"], line, reserved)
            if hit is None:
                continue
            name, kind = hit
        else:
            if depth[row] != owner["depth"] + 1:
                continue
            hit = _match_rules(rules["member"], line, reserved)
            if hit is None:
                continue
            name, kind = hit
            name = f"{owner['name']}.{name}"
        found.append({"name": name, "kind": kind, "line": row + 1, "end_line": block_end(blanked, close, row) + 1})

    return found


_RUBY_DEF = re.compile(r"^(?P<indent>[ \t]*)def\s+(?:self\.)?(?P<name>[A-Za-z_][\w]*[?!=]?)")
_RUBY_TYPE = re.compile(r"^(?P<indent>[ \t]*)(?P<kind>class|module)\s+(?P<name>[A-Z][\w:]*)")
_RUBY_END = re.compile(r"^(?P<indent>[ \t]*)end\b")
_RUBY_ENDLESS = re.compile(r"^[ \t]*def\s+[^=]*\)?\s*=\s*\S")


def _ruby_symbols(text: str) -> list[dict]:
    _, blanked, _, _ = scan_source(text, "ruby")
    found: list[dict] = []
    scopes: list[tuple[str, str]] = []

    def close_scopes(indent: str) -> None:
        while scopes and len(scopes[-1][1]) >= len(indent):
            scopes.pop()

    for row, line in enumerate(blanked):
        stripped = line.strip()
        if not stripped:
            continue
        end_match = _RUBY_END.match(line)
        if end_match:
            close_scopes(end_match.group("indent"))
            continue
        type_match = _RUBY_TYPE.match(line)
        if type_match:
            indent = type_match.group("indent")
            close_scopes(indent)
            prefix = scopes[-1][0] if scopes else ""
            name = f"{prefix}.{type_match.group('name')}" if prefix else type_match.group("name")
            found.append(
                {
                    "name": name,
                    "kind": type_match.group("kind"),
                    "line": row + 1,
                    "end_line": _ruby_block_end(blanked, row, indent) + 1,
                }
            )
            scopes.append((name, indent))
            continue
        def_match = _RUBY_DEF.match(line)
        if def_match:
            indent = def_match.group("indent")
            close_scopes(indent)
            prefix = scopes[-1][0] if scopes else ""
            name = f"{prefix}.{def_match.group('name')}" if prefix else def_match.group("name")
            end = row if _RUBY_ENDLESS.match(line) else _ruby_block_end(blanked, row, indent)
            found.append(
                {
                    "name": name,
                    "kind": "method" if prefix else "function",
                    "line": row + 1,
                    "end_line": end + 1,
                }
            )
    return found


def _ruby_block_end(blanked: list[str], row: int, indent: str) -> int:
    for cursor in range(row + 1, len(blanked)):
        match = _RUBY_END.match(blanked[cursor])
        if match and match.group("indent") == indent:
            return cursor
    return row


# --------------------------------------------------------------------------- #
# imports
# --------------------------------------------------------------------------- #

_JS_FROM = re.compile(r"""\bfrom\s*['"]([^'"]+)['"]""")
_JS_DIRECT = re.compile(
    r"""(?:^|[;\s])import\s*['"]([^'"]+)['"]|\b(?:require|import)\s*\(\s*['"]([^'"]+)['"]\s*\)"""
)
_GO_IMPORT = re.compile(r"^\s*(?:[\w.]+\s+)?\"([^\"]+)\"", re.MULTILINE)
_GO_IMPORT_BLOCK = re.compile(r"import\s*\((.*?)\)", re.DOTALL)
_GO_IMPORT_SINGLE = re.compile(r"^\s*import\s+(?:[\w.]+\s+)?\"([^\"]+)\"", re.MULTILINE)
_RUST_MOD = re.compile(r"^\s*(?:pub(?:\s*\([^)]*\))?\s+)?mod\s+(\w+)\s*;", re.MULTILINE)
_RUST_USE = re.compile(r"^\s*(?:pub\s+)?use\s+([\w:]+)", re.MULTILINE)
_JVM_IMPORT = re.compile(r"^\s*import\s+(?:static\s+)?([\w.]+)\s*[;*]?", re.MULTILINE)
_CS_USING = re.compile(r"^\s*(?:global\s+)?using\s+(?:static\s+)?([\w.]+)\s*;", re.MULTILINE)
_RUBY_REQUIRE = re.compile(r"^\s*require(?:_relative)?\s+['\"]([^'\"]+)['\"]", re.MULTILINE)
_PHP_USE = re.compile(r"^\s*use\s+([\w\\]+)", re.MULTILINE)
_PHP_INCLUDE = re.compile(r"\b(?:require|include)(?:_once)?\s*\(?\s*['\"]([^'\"]+)['\"]", re.MULTILINE)
_SWIFT_IMPORT = re.compile(r"^\s*import\s+(\w+)", re.MULTILINE)


def _js_imports(text: str) -> list[str]:
    """Scan only the lines that can carry a specifier; ``from`` is matched alone so
    that multi-line import lists still resolve."""
    specs: list[str] = []
    for line in text.split("\n"):
        if "from" in line:
            specs.extend(_JS_FROM.findall(line))
        if "import" in line or "require" in line:
            for match in _JS_DIRECT.finditer(line):
                specs.append(match.group(1) or match.group(2))
    return specs


def _regex_imports(text: str, language: str) -> list[str]:
    if language in ("typescript", "javascript"):
        return _js_imports(text)
    if language == "go":
        specs = _GO_IMPORT_SINGLE.findall(text)
        for block in _GO_IMPORT_BLOCK.findall(text):
            specs.extend(_GO_IMPORT.findall(block))
        return specs
    if language == "rust":
        return [f"mod:{name}" for name in _RUST_MOD.findall(text)] + _RUST_USE.findall(text)
    if language in ("java", "kotlin", "scala"):
        return _JVM_IMPORT.findall(text)
    if language == "csharp":
        return _CS_USING.findall(text)
    if language == "ruby":
        return _RUBY_REQUIRE.findall(text)
    if language == "php":
        return _PHP_USE.findall(text) + _PHP_INCLUDE.findall(text)
    if language == "swift":
        return _SWIFT_IMPORT.findall(text)
    return []


def extract(text: str, language: str) -> tuple[list[dict], list[str]]:
    """Return (symbols, raw import specifiers) for one source file."""
    if language == "python":
        try:
            tree = ast.parse(text)
        except (SyntaxError, ValueError, RecursionError):
            return [], []
        return _python_symbols(tree), _python_imports(tree)
    if language == "ruby":
        return _ruby_symbols(text), _regex_imports(text, language)
    return _regex_symbols(text, language), _regex_imports(text, language)


_CALL_PATTERN = re.compile(r"\b([A-Za-z_]\w{2,})\s*\(")
# Control-flow and builtin noise that reads like a call in every C-family
# language. Resolution against the defined-symbol table is the real filter;
# this list just keeps junk from crowding out real callees before the cap.
_CALL_NOISE = frozenset(
    """if for while switch catch return function typeof sizeof new await async yield
    assert raise print super this self elif else match case until unless defined require
    import include extend when then begin rescue lambda not and puts len str int dict list
    set type isinstance issubclass getattr setattr hasattr repr range enumerate sorted
    println printf sprintf format panic try do let var const fn func def end""".split()
)
MAX_CALLEE_NAMES = 30


def callees(text: str, language: str) -> list[str]:
    """Names this file appears to call, most frequent first, capped.

    Python answers from the AST; every other language matches ``name(`` against
    the blanked source, so calls inside strings and comments never count.
    """
    counts: dict[str, int] = {}
    if language == "python":
        try:
            tree = ast.parse(text)
        except (SyntaxError, ValueError, RecursionError):
            return []
        for node in ast.walk(tree):
            if not isinstance(node, ast.Call):
                continue
            target = node.func
            name = None
            if isinstance(target, ast.Name):
                name = target.id
            elif isinstance(target, ast.Attribute):
                name = target.attr
            if name and len(name) >= 3 and name not in _CALL_NOISE:
                counts[name] = counts.get(name, 0) + 1
    else:
        rules = _LANGUAGE_RULES.get(language)
        reserved = rules["reserved"] if rules else frozenset()
        try:
            _, blanked, _, _ = scan_source(text, language)
        except Exception:
            return []
        for line in blanked:
            for match in _CALL_PATTERN.finditer(line):
                name = match.group(1)
                if name in _CALL_NOISE or name in reserved:
                    continue
                counts[name] = counts.get(name, 0) + 1
    ranked = sorted(counts.items(), key=lambda item: (-item[1], item[0]))
    return [name for name, _count in ranked[:MAX_CALLEE_NAMES]]


_JS_EXTENSIONS = (".ts", ".tsx", ".d.ts", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".json")


def _normalize(path: str) -> str:
    normalized = posixpath.normpath(path.replace("\\", "/"))
    if normalized.startswith(".."):
        return ""
    if normalized.startswith("./"):
        normalized = normalized[2:]
    return "" if normalized == "." else normalized


def _resolve_js(spec: str, rel_path: str, known: set[str]) -> str | None:
    if not spec.startswith("."):
        return None
    base = _normalize(posixpath.join(posixpath.dirname(rel_path), spec))
    if not base:
        return None
    if base in known:
        return base
    for extension in _JS_EXTENSIONS:
        candidate = base + extension
        if candidate in known:
            return candidate
        if base.endswith(".js"):
            swapped = base[:-3] + extension
            if swapped in known:
                return swapped
    for extension in _JS_EXTENSIONS:
        candidate = f"{base}/index{extension}"
        if candidate in known:
            return candidate
    return None


def _resolve_python(spec: str, rel_path: str, known: set[str]) -> str | None:
    directory = posixpath.dirname(rel_path)
    if spec.startswith("."):
        level = len(spec) - len(spec.lstrip("."))
        module = spec[level:]
        base = directory
        for _ in range(level - 1):
            base = posixpath.dirname(base)
        parts = [part for part in module.split(".") if part]
        target = posixpath.join(base, *parts) if parts else base
        return _python_module_file(_normalize(target), known)

    parts = [part for part in spec.split(".") if part]
    if not parts:
        return None
    bases = [""]
    walker = directory
    while walker:
        bases.append(walker)
        walker = posixpath.dirname(walker)
    for extra in ("src", "lib"):
        if extra not in bases:
            bases.append(extra)
    for base in bases:
        target = posixpath.join(base, *parts) if base else "/".join(parts)
        resolved = _python_module_file(_normalize(target), known)
        if resolved:
            return resolved
    return None


def _python_module_file(target: str, known: set[str]) -> str | None:
    if not target:
        return None
    for candidate in (f"{target}.py", f"{target}/__init__.py", f"{target}.pyi"):
        if candidate in known:
            return candidate
    return None


def _resolve_suffix(spec: str, extension: str, known: set[str], suffix_index: dict[str, str]) -> str | None:
    parts = [part for part in re.split(r"[.\\]", spec) if part]
    while parts:
        key = "/".join(parts) + extension
        hit = suffix_index.get(key)
        if hit:
            return hit
        parts = parts[:-1]
    return None


def resolve_import(
    spec: str,
    rel_path: str,
    language: str,
    known: set[str],
    suffix_index: dict[str, str],
) -> str | None:
    """Map an import specifier onto a repo-relative path, or None when external."""
    if language in ("typescript", "javascript"):
        return _resolve_js(spec, rel_path, known)
    if language == "python":
        return _resolve_python(spec, rel_path, known)
    if language == "rust":
        if spec.startswith("mod:"):
            name = spec[4:]
            directory = posixpath.dirname(rel_path)
            stem = posixpath.splitext(posixpath.basename(rel_path))[0]
            candidates = [
                _normalize(posixpath.join(directory, f"{name}.rs")),
                _normalize(posixpath.join(directory, name, "mod.rs")),
            ]
            if stem not in ("mod", "lib", "main"):
                candidates.append(_normalize(posixpath.join(directory, stem, f"{name}.rs")))
            for candidate in candidates:
                if candidate in known:
                    return candidate
            return None
        parts = [part for part in spec.split("::") if part and part not in ("crate", "self", "super")]
        if not parts:
            return None
        return _resolve_suffix(".".join(parts), ".rs", known, suffix_index)
    if language == "java":
        return _resolve_suffix(spec, ".java", known, suffix_index)
    if language == "kotlin":
        return _resolve_suffix(spec, ".kt", known, suffix_index)
    if language == "scala":
        return _resolve_suffix(spec, ".scala", known, suffix_index)
    if language == "csharp":
        return _resolve_suffix(spec, ".cs", known, suffix_index)
    if language == "php":
        if spec.startswith(".") or spec.endswith(".php"):
            candidate = _normalize(posixpath.join(posixpath.dirname(rel_path), spec))
            return candidate if candidate in known else None
        return _resolve_suffix(spec, ".php", known, suffix_index)
    if language == "ruby":
        candidate = _normalize(posixpath.join(posixpath.dirname(rel_path), spec + ".rb"))
        if candidate in known:
            return candidate
        return _resolve_suffix(spec.replace("/", "."), ".rb", known, suffix_index)
    return None
