|
| 1 | +"""Luau import resolution. |
| 2 | +
|
| 3 | +Luau's ``require(...)`` accepts four kinds of argument: |
| 4 | +
|
| 5 | +1. String literals — ``require("./helper")`` or ``require("some/path")`` |
| 6 | + (plain Lua style + Luau's new require-by-string). |
| 7 | +2. Relative instance paths — ``require(script.Parent.Foo)``, |
| 8 | + ``require(script.Foo)``, or the Rojo-safe variant |
| 9 | + ``require(script.Parent:WaitForChild("Foo"))``. |
| 10 | +3. Absolute Roblox instance paths — ``require(game.ReplicatedStorage.Foo)``, |
| 11 | + where the leading service is resolved against a Rojo project's ``tree`` |
| 12 | + mapping in ``default.project.json``. |
| 13 | +4. ``.luaurc``-aliased requires — ``require("@dep")``, resolved by reading |
| 14 | + ``.luaurc`` files along the directory hierarchy for an ``aliases`` map. |
| 15 | +
|
| 16 | +This resolver handles (1) and (2). (3) requires a Rojo ``default.project.json`` |
| 17 | +reader layered in via ``core/ingestion/dynamic_hints/rojo.py``; (4) requires a |
| 18 | +``.luaurc`` reader analogous to the tsconfig resolver. Both are deferred to |
| 19 | +follow-ups — see the xfail tests in ``test_luau_resolver.py``. |
| 20 | +
|
| 21 | +Unresolved paths are intentionally *not* silently matched by filename — a |
| 22 | +wrong edge is worse than no edge when the downstream graph feeds docs and |
| 23 | +dead-code detection. They fall through to ``add_external_node`` so they |
| 24 | +still appear in the graph as external references. |
| 25 | +
|
| 26 | +Parser contract note |
| 27 | +-------------------- |
| 28 | +The tree-sitter query in ``queries/luau.scm`` captures the raw argument node; |
| 29 | +``parser.py`` then normalizes the captured text with ``.strip("\"'` ")`` |
| 30 | +before calling this function. String-literal requires therefore arrive here |
| 31 | +*without* their surrounding quotes — e.g. ``require("./helper")`` reaches this |
| 32 | +function as ``./helper``, not ``"./helper"``. We identify the literal branch |
| 33 | +by process of elimination (doesn't parse as ``script.X`` or ``game.X``). |
| 34 | +""" |
| 35 | + |
| 36 | +from __future__ import annotations |
| 37 | + |
| 38 | +import re |
| 39 | +from pathlib import PurePosixPath |
| 40 | + |
| 41 | +from .context import ResolverContext |
| 42 | + |
| 43 | +# `script.Parent.Foo.Bar` / `script.Foo` — capture everything after the leading |
| 44 | +# `script` so we can walk up/down from the importer. |
| 45 | +_SCRIPT_RELATIVE = re.compile(r"^\s*script\s*((?:\.\s*\w+\s*)+)\s*$") |
| 46 | + |
| 47 | +# `game.<Service>.<Path>...` — capture the service and the remainder. |
| 48 | +_GAME_ABSOLUTE = re.compile(r"^\s*game\s*\.\s*(\w+)\s*((?:\.\s*\w+\s*)*)$") |
| 49 | + |
| 50 | +# Roblox name-lookup method calls: `:WaitForChild("Foo")` / `:FindFirstChild("Foo")`. |
| 51 | +# These are the race-safe idioms actual Rojo code uses in place of the bare |
| 52 | +# `.Foo` field access — on OSRPS they account for ~93% of all `require(...)` |
| 53 | +# arguments. The name-resolution semantics are identical (look up a child of |
| 54 | +# the preceding instance by string name), so we normalize both forms to the |
| 55 | +# dot-chain shape before the `_SCRIPT_RELATIVE` regex runs. Optional second |
| 56 | +# argument (timeout) is swallowed. |
| 57 | +_INSTANCE_METHOD_CALL = re.compile( |
| 58 | + r":\s*(?:WaitForChild|FindFirstChild)\s*\(\s*[\"']([A-Za-z_]\w*)[\"']\s*(?:,\s*[^)]+)?\)" |
| 59 | +) |
| 60 | + |
| 61 | +_LUAU_SUFFIXES: tuple[str, ...] = (".luau", ".lua") |
| 62 | + |
| 63 | + |
| 64 | +def _normalize_instance_methods(arg: str) -> str: |
| 65 | + """Rewrite `:WaitForChild("Foo")` / `:FindFirstChild("Foo")` as `.Foo`. |
| 66 | +
|
| 67 | + Roblox relative-require idioms: ``script.Parent:WaitForChild("Foo")`` |
| 68 | + is semantically equivalent to ``script.Parent.Foo`` for module lookup |
| 69 | + purposes (both resolve to the child instance named ``Foo``), but only |
| 70 | + the dot form was matched by ``_SCRIPT_RELATIVE``. Normalizing up |
| 71 | + front keeps a single regex for both shapes and avoids duplicating the |
| 72 | + path-walking logic in ``_resolve_script_relative``. |
| 73 | + """ |
| 74 | + return _INSTANCE_METHOD_CALL.sub(lambda m: f".{m.group(1)}", arg) |
| 75 | + |
| 76 | + |
| 77 | +def resolve_luau_import( |
| 78 | + module_path: str, |
| 79 | + importer_path: str, |
| 80 | + ctx: ResolverContext, |
| 81 | +) -> str | None: |
| 82 | + """Resolve a Luau ``require(...)`` argument to a repo-relative file path. |
| 83 | +
|
| 84 | + ``module_path`` is the argument text captured by ``luau.scm`` after the |
| 85 | + parser's quote-strip pass (see module docstring). It may be a bare |
| 86 | + filesystem path (from a string-literal require), an instance-path |
| 87 | + expression such as ``script.Parent.Foo`` or |
| 88 | + ``script.Parent:WaitForChild("Foo")``, or an ``@alias`` reference. |
| 89 | + """ |
| 90 | + raw = module_path.strip() |
| 91 | + arg = _normalize_instance_methods(raw) |
| 92 | + |
| 93 | + # Relative instance path: script[.Parent]*.Name[.Name]* |
| 94 | + # Matched against the normalized form so `:WaitForChild("Foo")` chains |
| 95 | + # resolve the same as `.Foo` chains. Unresolved paths fall through with |
| 96 | + # the *original* text so external-node labels reflect what was actually |
| 97 | + # written at the call site. |
| 98 | + m = _SCRIPT_RELATIVE.match(arg) |
| 99 | + if m: |
| 100 | + parts = [p.strip() for p in m.group(1).split(".") if p.strip()] |
| 101 | + resolved = _resolve_script_relative(parts, importer_path, ctx) |
| 102 | + if resolved is not None: |
| 103 | + return resolved |
| 104 | + return ctx.add_external_node(raw) |
| 105 | + |
| 106 | + # Absolute instance path: game.<Service>.Path... |
| 107 | + # Full Rojo-tree resolution is deferred to the Rojo follow-up; fall through |
| 108 | + # to an external node so the graph still records the reference. |
| 109 | + if _GAME_ABSOLUTE.match(arg): |
| 110 | + return ctx.add_external_node(raw) |
| 111 | + |
| 112 | + # `.luaurc` alias: require("@dep"). Needs a luaurc reader; deferred. |
| 113 | + if raw.startswith("@"): |
| 114 | + return ctx.add_external_node(raw) |
| 115 | + |
| 116 | + # Everything else is a string-literal path. The parser has already |
| 117 | + # stripped surrounding quotes, so `raw` is e.g. `./helper` or |
| 118 | + # `some/path`. `_resolve_literal` handles both relative and stem-match |
| 119 | + # resolution; unresolved literals fall through to an external node |
| 120 | + # without any silent filename guess. |
| 121 | + resolved = _resolve_literal(raw, importer_path, ctx) |
| 122 | + if resolved is not None: |
| 123 | + return resolved |
| 124 | + return ctx.add_external_node(raw) |
| 125 | + |
| 126 | + |
| 127 | +def _resolve_literal(literal: str, importer_path: str, ctx: ResolverContext) -> str | None: |
| 128 | + """Resolve a plain string require — relative or stem match.""" |
| 129 | + importer_dir = PurePosixPath(importer_path).parent |
| 130 | + candidate = (importer_dir / literal).as_posix() |
| 131 | + for suffix in _LUAU_SUFFIXES: |
| 132 | + full = f"{candidate}{suffix}" |
| 133 | + if full in ctx.path_set: |
| 134 | + return full |
| 135 | + if literal in ctx.path_set: |
| 136 | + return literal |
| 137 | + |
| 138 | + stem = PurePosixPath(literal).stem.lower().replace("-", "_") |
| 139 | + result = ctx.stem_lookup(stem) |
| 140 | + if result and any(result.endswith(s) for s in _LUAU_SUFFIXES): |
| 141 | + return result |
| 142 | + return None |
| 143 | + |
| 144 | + |
| 145 | +def _resolve_script_relative( |
| 146 | + parts: list[str], importer_path: str, ctx: ResolverContext |
| 147 | +) -> str | None: |
| 148 | + """Walk ``Parent``/name segments relative to the importing file. |
| 149 | +
|
| 150 | + Roblox semantics: ``script`` is the importing module instance; its |
| 151 | + ``script.Parent`` is the *container* that holds it. For Rojo-synced |
| 152 | + code, a ``.luau``/``.lua`` file lives inside its container directory, |
| 153 | + so ``script.Parent`` is that directory. This means the *first* |
| 154 | + ``Parent`` segment is an identity (we're already there); each |
| 155 | + subsequent ``Parent`` walks one more level up. |
| 156 | +
|
| 157 | + After the leading ``Parent`` run, any remaining identifiers descend |
| 158 | + into child instances by name. The terminal segment resolves to either |
| 159 | + ``<name>.luau``/``<name>.lua`` or a directory with |
| 160 | + ``init.luau``/``init.lua``. |
| 161 | + """ |
| 162 | + here = PurePosixPath(importer_path).parent |
| 163 | + i = 0 |
| 164 | + # First "Parent" is a no-op — `here` already represents script.Parent. |
| 165 | + if i < len(parts) and parts[i] == "Parent": |
| 166 | + i += 1 |
| 167 | + # Each subsequent "Parent" walks up one level. |
| 168 | + while i < len(parts) and parts[i] == "Parent": |
| 169 | + here = here.parent |
| 170 | + i += 1 |
| 171 | + |
| 172 | + remainder = parts[i:] |
| 173 | + if not remainder: |
| 174 | + return None |
| 175 | + |
| 176 | + base = here |
| 177 | + for seg in remainder[:-1]: |
| 178 | + base = base / seg |
| 179 | + |
| 180 | + name = remainder[-1] |
| 181 | + |
| 182 | + # Module-as-file: <base>/<name>.luau|.lua |
| 183 | + for suffix in _LUAU_SUFFIXES: |
| 184 | + candidate = (base / f"{name}{suffix}").as_posix() |
| 185 | + if candidate in ctx.path_set: |
| 186 | + return candidate |
| 187 | + |
| 188 | + # Module-as-directory: <base>/<name>/init.luau|.lua |
| 189 | + for suffix in _LUAU_SUFFIXES: |
| 190 | + candidate = (base / name / f"init{suffix}").as_posix() |
| 191 | + if candidate in ctx.path_set: |
| 192 | + return candidate |
| 193 | + |
| 194 | + return None |
0 commit comments