|
| 1 | +"""Definition index — where each name in the code is actually defined. |
| 2 | +
|
| 3 | +Reading unfamiliar code means constantly hitting a name and wondering "where |
| 4 | +does this come from?". A call to ``parse_config``, a ``class Scanner``, an |
| 5 | +imported helper — the reader wants the file and line that defines it, not a web |
| 6 | +search. This module builds that index from the source CodeABC already loaded, |
| 7 | +with no LLM and no language server: it scans for top-level definitions (and one |
| 8 | +level of class methods) and records where each one lives, so the UI can offer |
| 9 | +"jump to definition" for any name a reader clicks. |
| 10 | +
|
| 11 | +It covers the two languages CodeABC annotates today: |
| 12 | +
|
| 13 | + Python ``def`` / ``async def`` / ``class`` at module level, plus methods |
| 14 | + one indent inside a class (recorded with their enclosing class). |
| 15 | + JS / TS ``function`` / ``async function`` / ``export function`` / ``class``, |
| 16 | + and ``const name = (...) =>`` / ``const name = function`` style |
| 17 | + assignments. |
| 18 | +
|
| 19 | +Each entry carries the name, its kind (function / class / method), the |
| 20 | +enclosing class for a method, the file, and a 1-based line number. |
| 21 | +
|
| 22 | +Limitations (kept honest on purpose): |
| 23 | +
|
| 24 | + * Matching is regex over indentation, not a real parser. Decorators that |
| 25 | + rename, ``setattr`` / ``globals()`` tricks, conditional re-exports, and |
| 26 | + names built at runtime are out of scope. |
| 27 | + * Only definitions are indexed, not references — this points at where a name |
| 28 | + is born, not everywhere it is used. |
| 29 | + * A name defined in several files yields one entry per file; the caller |
| 30 | + decides which is meant. |
| 31 | +""" |
| 32 | + |
| 33 | +from __future__ import annotations |
| 34 | + |
| 35 | +import re |
| 36 | + |
| 37 | +# --------------------------------------------------------------------------- |
| 38 | +# Shared helpers |
| 39 | +# --------------------------------------------------------------------------- |
| 40 | + |
| 41 | +_IDENT = r"[A-Za-z_$][\w$]*" |
| 42 | + |
| 43 | + |
| 44 | +def _lang(path: str) -> str: |
| 45 | + ext = path.rsplit(".", 1)[-1].lower() if "." in path else "" |
| 46 | + if ext == "py": |
| 47 | + return "python" |
| 48 | + if ext in ("js", "ts", "jsx", "tsx", "mjs", "cjs"): |
| 49 | + return "js" |
| 50 | + return ext |
| 51 | + |
| 52 | + |
| 53 | +def _indent_width(line: str) -> int: |
| 54 | + expanded = line.expandtabs(4) |
| 55 | + return len(expanded) - len(expanded.lstrip()) |
| 56 | + |
| 57 | + |
| 58 | +# --------------------------------------------------------------------------- |
| 59 | +# Python |
| 60 | +# --------------------------------------------------------------------------- |
| 61 | + |
| 62 | +_PY_DEF_RE = re.compile(r"^(?P<indent>\s*)(?:async\s+)?def\s+(?P<name>\w+)\s*\(") |
| 63 | +_PY_CLASS_RE = re.compile(r"^(?P<indent>\s*)class\s+(?P<name>\w+)\s*[:(]") |
| 64 | + |
| 65 | + |
| 66 | +def _py_definitions(path: str, content: str) -> list[dict]: |
| 67 | + """Top-level functions/classes plus direct (and nested-class) methods. |
| 68 | +
|
| 69 | + A stack of open ``class`` / ``def`` blocks, keyed by indentation, tells us |
| 70 | + what encloses each definition. A ``def`` whose nearest enclosing block is a |
| 71 | + class is a method; a ``def`` nested inside another ``def`` is a local helper |
| 72 | + and is skipped (it is not part of the public reading surface). |
| 73 | + """ |
| 74 | + out: list[dict] = [] |
| 75 | + # frames: list of (indent, kind, name) for currently open class/def blocks |
| 76 | + frames: list[tuple[int, str, str]] = [] |
| 77 | + |
| 78 | + for lineno, raw in enumerate(content.splitlines(), start=1): |
| 79 | + stripped = raw.strip() |
| 80 | + if not stripped or stripped.startswith("#"): |
| 81 | + continue |
| 82 | + |
| 83 | + class_match = _PY_CLASS_RE.match(raw) |
| 84 | + def_match = None if class_match else _PY_DEF_RE.match(raw) |
| 85 | + if class_match is None and def_match is None: |
| 86 | + continue |
| 87 | + |
| 88 | + indent = _indent_width(raw) |
| 89 | + # close every block that this line is not nested inside |
| 90 | + while frames and frames[-1][0] >= indent: |
| 91 | + frames.pop() |
| 92 | + enclosing = frames[-1] if frames else None |
| 93 | + |
| 94 | + if class_match is not None: |
| 95 | + name = class_match.group("name") |
| 96 | + parent = enclosing[2] if enclosing and enclosing[1] == "class" else None |
| 97 | + out.append(_entry(path, "python", name, "class", parent, lineno)) |
| 98 | + frames.append((indent, "class", name)) |
| 99 | + continue |
| 100 | + |
| 101 | + assert def_match is not None # narrowing: class_match is None here |
| 102 | + name = def_match.group("name") |
| 103 | + if enclosing is None: |
| 104 | + out.append(_entry(path, "python", name, "function", None, lineno)) |
| 105 | + frames.append((indent, "def", name)) |
| 106 | + elif enclosing[1] == "class": |
| 107 | + out.append(_entry(path, "python", name, "method", enclosing[2], lineno)) |
| 108 | + frames.append((indent, "def", name)) |
| 109 | + else: |
| 110 | + # nested inside another def: a local helper — track it so its own |
| 111 | + # nested blocks resolve correctly, but do not index it. |
| 112 | + frames.append((indent, "def", name)) |
| 113 | + |
| 114 | + return out |
| 115 | + |
| 116 | + |
| 117 | +# --------------------------------------------------------------------------- |
| 118 | +# JavaScript / TypeScript |
| 119 | +# --------------------------------------------------------------------------- |
| 120 | + |
| 121 | +_JS_FUNC_RE = re.compile( |
| 122 | + r"^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*(" + _IDENT + r")\s*\(" |
| 123 | +) |
| 124 | +_JS_CLASS_RE = re.compile( |
| 125 | + r"^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+(" + _IDENT + r")\b" |
| 126 | +) |
| 127 | +# const/let/var name = (...) => ... or = async (...) => or = function |
| 128 | +_JS_ASSIGN_RE = re.compile( |
| 129 | + r"^\s*(?:export\s+)?(?:default\s+)?(?:const|let|var)\s+(" + _IDENT + r")\s*=\s*" |
| 130 | + r"(?:async\s+)?(?:function\b|\*?\s*\([^)]*\)\s*=>|" + _IDENT + r"\s*=>)" |
| 131 | +) |
| 132 | + |
| 133 | + |
| 134 | +def _js_definitions(path: str, content: str) -> list[dict]: |
| 135 | + out: list[dict] = [] |
| 136 | + for lineno, raw in enumerate(content.splitlines(), start=1): |
| 137 | + stripped = raw.strip() |
| 138 | + if not stripped or stripped.startswith("//"): |
| 139 | + continue |
| 140 | + |
| 141 | + class_match = _JS_CLASS_RE.match(raw) |
| 142 | + if class_match: |
| 143 | + out.append(_entry(path, "js", class_match.group(1), "class", None, lineno)) |
| 144 | + continue |
| 145 | + |
| 146 | + func_match = _JS_FUNC_RE.match(raw) |
| 147 | + if func_match: |
| 148 | + out.append(_entry(path, "js", func_match.group(1), "function", None, lineno)) |
| 149 | + continue |
| 150 | + |
| 151 | + assign_match = _JS_ASSIGN_RE.match(raw) |
| 152 | + if assign_match: |
| 153 | + out.append(_entry(path, "js", assign_match.group(1), "function", None, lineno)) |
| 154 | + |
| 155 | + return out |
| 156 | + |
| 157 | + |
| 158 | +# --------------------------------------------------------------------------- |
| 159 | +# Assembly |
| 160 | +# --------------------------------------------------------------------------- |
| 161 | + |
| 162 | + |
| 163 | +def _entry(path: str, lang: str, name: str, kind: str, parent: str | None, line: int) -> dict: |
| 164 | + qualname = f"{parent}.{name}" if parent else name |
| 165 | + return { |
| 166 | + "name": name, |
| 167 | + "qualname": qualname, |
| 168 | + "kind": kind, |
| 169 | + "parent": parent, |
| 170 | + "lang": lang, |
| 171 | + "file": path, |
| 172 | + "line": line, |
| 173 | + } |
| 174 | + |
| 175 | + |
| 176 | +def _build_notes(definitions: list[dict], file_contents: dict[str, str]) -> list[str]: |
| 177 | + notes: list[str] = [] |
| 178 | + scanned = sum(1 for p in file_contents if _lang(p) in ("python", "js")) |
| 179 | + if scanned == 0: |
| 180 | + notes.append("No Python or JS/TS files were found to index.") |
| 181 | + return notes |
| 182 | + |
| 183 | + by_kind: dict[str, int] = {} |
| 184 | + for d in definitions: |
| 185 | + by_kind[d["kind"]] = by_kind.get(d["kind"], 0) + 1 |
| 186 | + parts = [f"{by_kind[k]} {k}{'es' if k == 'class' else 's'}" for k in sorted(by_kind)] |
| 187 | + if parts: |
| 188 | + notes.append(f"Indexed {', '.join(parts)} across {scanned} source file(s).") |
| 189 | + |
| 190 | + duplicates = _duplicate_names(definitions) |
| 191 | + if duplicates: |
| 192 | + sample = ", ".join(duplicates[:5]) |
| 193 | + notes.append( |
| 194 | + f"{len(duplicates)} name(s) are defined in more than one place " |
| 195 | + f"(e.g. {sample}); a lookup returns every match." |
| 196 | + ) |
| 197 | + |
| 198 | + notes.append( |
| 199 | + "Definitions only: this index shows where a name is declared, not " |
| 200 | + "every place it is used. Matching is regex-based, so runtime-built or " |
| 201 | + "re-exported names may be missed." |
| 202 | + ) |
| 203 | + return notes |
| 204 | + |
| 205 | + |
| 206 | +def _duplicate_names(definitions: list[dict]) -> list[str]: |
| 207 | + seen: dict[str, int] = {} |
| 208 | + for d in definitions: |
| 209 | + seen[d["name"]] = seen.get(d["name"], 0) + 1 |
| 210 | + return sorted(name for name, count in seen.items() if count > 1) |
| 211 | + |
| 212 | + |
| 213 | +def build_definition_index(file_contents: dict[str, str], *, limit: int = 2000) -> dict: |
| 214 | + """Index every top-level definition (and class method) in the project. |
| 215 | +
|
| 216 | + Returns ``{"total", "definitions", "notes"}`` where ``definitions`` is a |
| 217 | + deterministic, alphabetically sorted list of entries (see :func:`_entry`), |
| 218 | + truncated to ``limit``. |
| 219 | + """ |
| 220 | + definitions: list[dict] = [] |
| 221 | + for path in sorted(file_contents): |
| 222 | + lang = _lang(path) |
| 223 | + content = file_contents[path] |
| 224 | + if lang == "python": |
| 225 | + definitions.extend(_py_definitions(path, content)) |
| 226 | + elif lang == "js": |
| 227 | + definitions.extend(_js_definitions(path, content)) |
| 228 | + |
| 229 | + definitions.sort(key=lambda d: (d["name"].lower(), d["file"], d["line"])) |
| 230 | + notes = _build_notes(definitions, file_contents) |
| 231 | + total = len(definitions) |
| 232 | + return {"total": total, "definitions": definitions[:limit], "notes": notes} |
| 233 | + |
| 234 | + |
| 235 | +def find_definition(file_contents: dict[str, str], name: str, *, limit: int = 50) -> list[dict]: |
| 236 | + """Return the places that define ``name``. |
| 237 | +
|
| 238 | + Tries an exact match first, then falls back to a case-insensitive match so |
| 239 | + a reader who types ``scanner`` still finds ``Scanner``. Results are ordered |
| 240 | + by file then line. |
| 241 | + """ |
| 242 | + target = (name or "").strip() |
| 243 | + if not target: |
| 244 | + return [] |
| 245 | + |
| 246 | + index = build_definition_index(file_contents, limit=100_000) |
| 247 | + matches = [d for d in index["definitions"] if d["name"] == target] |
| 248 | + if not matches: |
| 249 | + lowered = target.lower() |
| 250 | + matches = [d for d in index["definitions"] if d["name"].lower() == lowered] |
| 251 | + |
| 252 | + matches.sort(key=lambda d: (d["file"], d["line"])) |
| 253 | + return matches[:limit] |
0 commit comments