Skip to content

Commit e4531ec

Browse files
CyanoTexclaudeRaghavChamadiya
authored
feat(luau): add Luau/Roblox language support (issue #52) (#89)
* feat(luau): add Luau/Roblox language support (#52) Adds a `luau` tier covering `.luau` and `.lua` files: LanguageSpec, LanguageTag, tree-sitter-luau grammar wiring, .scm captures for function/type/require, a dedicated import resolver, and unit tests. The resolver handles `require("rel/path")` and `script` / `script.Parent` relative instance paths. `game.<Service>...` absolute instance paths currently register as external nodes; full Rojo-aware resolution via `default.project.json` is deferred to a follow-up and pinned by an `xfail` test documenting the expected end state. Replaces the existing git-blame-only `lua` spec at registry.py rather than leaving two sources of truth for the same extension set. Refs: #52 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(luau): string-literal requires never entered the resolver parser.py:705 normalizes the captured @import.module text with .strip("\"'` ") before handing it to resolve_luau_import, but the resolver's string-literal branch identified literals by checking arg.startswith('"') — so every require("./path") landed on the external-node fallback in production. The unit tests masked this by passing explicitly-quoted strings, which the production parser never emits. Surfaced by running `repowise init --index-only` against the upstream luau-lang/luau repo (per maintainer's request to test against live systems): 146/146 string-literal requires — including 107 instances of require("../bench_support") — went to external when they should have resolved internally. Fix: - Drop the unreachable quote-detection branch. Detect literal paths by elimination after the script./game. regex checks. - Update the test class to pass *unquoted* input mirroring what the parser actually emits. Add cases for ../ relative paths and stem matching so the production handoff is exercised end-to-end. - Document the parser contract in both the resolver and test module docstrings so the next reader doesn't reintroduce the mismatch. The @alias (.luaurc) branch is deferred alongside the existing Rojo follow-up and records the reference as external for now. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(luau): resolve :WaitForChild / :FindFirstChild require chains The `script.Parent:WaitForChild("Foo")` and `script.X:FindFirstChild("Foo")` idioms are how shipping Rojo code loads modules — they guard against the sync-order race between Rojo's filesystem stream and `require()`. On OSRPS they account for **451 of 485** `require(...)` calls (93%); the bare-dot-chain form that the previous resolver matched accounts for only 13 (2.7%). Everything else fell through to the external-node fallback, leaving the graph's Luau edges almost entirely unresolved. Fix: normalize `:WaitForChild("Name")` / `:FindFirstChild("Name")` into `.Name` before the `_SCRIPT_RELATIVE` regex runs. Both forms have identical name-resolution semantics (child lookup by string on the preceding instance), so the path-walking logic in `_resolve_script_relative` is reused unchanged. Design notes: - `WaitForChild`'s optional second `timeoutSeconds` argument is discarded by the regex — the timeout is runtime behavior and has no bearing on the target module's name. - External-node labels use the *original* pre-normalization text, so the graph shows what was written at the call site rather than a rewritten dot-chain form. - Method chains like `...:WaitForChild("shared"):WaitForChild("Signal")` normalize to `.shared.Signal` and resolve through the same code path. Variable-aliased chains (e.g. `SharedFolder:WaitForChild("X")` where `SharedFolder = ReplicatedStorage:WaitForChild("OSRPS")`) are a separate problem — they need lightweight flow tracking to resolve the alias and are deliberately out of scope here. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(luau): pin .luaurc @alias resolution as a follow-up (xfail) Luau's official require-by-string alias mechanism: a `.luaurc` file in the directory hierarchy declares `{"aliases": {"dep": "./dependency"}}`, and `require("@dep")` resolves through that map. Parallel in shape to tsconfig/jsconfig path aliases (already handled by the TS resolver) and to the Rojo follow-up this PR already pins. Counts from `repowise init` against upstream luau-lang/luau: 24 of the 170 string-literal-shaped requires are `@alias` references — every one currently lands on the external-node fallback. Implementation needs a `.luaurc` reader layered in via `core/ingestion/dynamic_hints/luaurc.py`, with: - parent-directory walk to find the nearest `.luaurc` - child `.luaurc` overriding parent aliases - alias values resolved as relative paths from the `.luaurc`'s dir Out of scope for this PR — recorded here as a strict xfail so the expected end state is versioned alongside the existing Rojo xfail. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Raghav Chamadiya <65403859+RaghavChamadiya@users.noreply.github.com>
1 parent ded291f commit e4531ec

11 files changed

Lines changed: 548 additions & 6 deletions

File tree

docs/LANGUAGE_SUPPORT.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,26 @@ endpoints or targets where applicable.
8888
| **SQL** | `.sql` | -- |
8989
| **Shell** | `.sh` `.bash` `.zsh` | -- |
9090

91+
### Partial (Luau — Roblox)
92+
93+
| Language | Extensions | Entry Points | Import Style |
94+
|----------|-----------|-------------|-------------|
95+
| **Luau** | `.luau` `.lua` | `init.luau` `init.lua` | `require(script.Parent.X)` / `require(script.X)` / `require(game.Service.Path)` / `require("rel/path")` |
96+
97+
AST parsing, symbol extraction (functions, Luau type aliases), and
98+
`require(...)` call capture are wired. Import resolution handles string
99+
literals and `script`/`script.Parent` relative instance paths. Absolute
100+
Roblox instance paths (`game.<Service>...`) currently register as external
101+
nodes and are the target of a follow-up that reads Rojo's
102+
`default.project.json` tree mapping — see issue #52.
103+
91104
### Git-Blame-Only
92105

93106
These languages are tracked in git history (blame, hotspot analysis,
94107
co-change detection) but have no AST parsing or dedicated support. Files
95108
appear in the wiki as traversal-level entries.
96109

97-
Objective-C, Elixir, Erlang, Lua, R, Dart, Zig, Julia, Clojure, Elm,
110+
Objective-C, Elixir, Erlang, R, Dart, Zig, Julia, Clojure, Elm,
98111
Haskell, OCaml, F#, Crystal, Nim, D
99112

100113
---

packages/core/src/repowise/core/ingestion/languages/registry.py

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -974,10 +974,44 @@
974974
is_passthrough=True,
975975
),
976976
LanguageSpec(
977-
tag="lua",
978-
display_name="Lua",
979-
extensions=frozenset({".lua"}),
980-
is_passthrough=True,
977+
tag="luau",
978+
display_name="Luau",
979+
# Rojo treats both .lua and .luau as Luau modules. Luau's grammar is
980+
# a superset of Lua 5.1, so vanilla Lua files parse cleanly too.
981+
extensions=frozenset({".lua", ".luau"}),
982+
grammar_package="tree_sitter_luau",
983+
scm_file="luau.scm",
984+
heritage_node_types=frozenset(),
985+
entry_point_patterns=("init.luau", "init.lua"),
986+
manifest_files=("default.project.json", "wally.toml", ".rojo.json"),
987+
blocked_dirs=("Packages", "ServerPackages", "DevPackages"),
988+
builtin_calls=frozenset(
989+
{
990+
"print",
991+
"warn",
992+
"error",
993+
"assert",
994+
"pcall",
995+
"xpcall",
996+
"select",
997+
"type",
998+
"typeof",
999+
"tonumber",
1000+
"tostring",
1001+
"ipairs",
1002+
"pairs",
1003+
"next",
1004+
"rawget",
1005+
"rawset",
1006+
"rawequal",
1007+
"rawlen",
1008+
"setmetatable",
1009+
"getmetatable",
1010+
"unpack",
1011+
"require",
1012+
}
1013+
),
1014+
color_hex="#00A2FF",
9811015
),
9821016
LanguageSpec(
9831017
tag="r",

packages/core/src/repowise/core/ingestion/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"swift",
3434
"kotlin",
3535
"scala",
36+
"luau",
3637
"shell",
3738
"yaml",
3839
"json",

packages/core/src/repowise/core/ingestion/parser.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,18 @@ class LanguageConfig:
413413
),
414414
entry_point_patterns=["index.php", "public/index.php"],
415415
),
416+
"luau": LanguageConfig(
417+
symbol_node_types={
418+
"function_declaration": "function",
419+
"type_definition": "type_alias",
420+
},
421+
import_node_types=["function_call"],
422+
export_node_types=[],
423+
visibility_fn=public_by_default,
424+
parent_extraction="none",
425+
parent_class_types=frozenset(),
426+
entry_point_patterns=["init.luau", "init.lua"],
427+
),
416428
}
417429

418430

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
; =============================================================================
2+
; repowise — Luau symbol and import queries
3+
; tree-sitter-luau (install separately if needed)
4+
;
5+
; Luau is a gradually-typed superset of Lua 5.1 used by the Roblox engine.
6+
; Rojo maps filesystem layout to Roblox instance paths via default.project.json;
7+
; `require()` accepts instance paths such as `script.Parent.Foo`,
8+
; `script.Foo`, or `game.ReplicatedStorage.Shared.Foo`.
9+
;
10+
; Full Rojo-aware import resolution lives in resolvers/luau.py; this file
11+
; only emits symbol defs and the raw require-argument text as @import.module.
12+
; =============================================================================
13+
14+
; Global function: function foo.bar.baz() end
15+
(function_declaration
16+
name: (_) @symbol.name
17+
) @symbol.def
18+
19+
; Type alias: type Foo = ... (Luau-specific; Lua 5.1 has no `type`)
20+
(type_definition
21+
(identifier) @symbol.name
22+
) @symbol.def
23+
24+
; ---------------------------------------------------------------------------
25+
; Imports — captured as raw argument text for the resolver to parse.
26+
;
27+
; Matches:
28+
; require("some/string/path")
29+
; require(script.Parent.Foo)
30+
; require(game.ReplicatedStorage.Shared.Foo)
31+
;
32+
; The resolver is responsible for splitting out the instance path, consulting
33+
; Rojo's default.project.json tree, and producing a filesystem path.
34+
; ---------------------------------------------------------------------------
35+
(function_call
36+
(identifier) @_require_name
37+
(arguments (_) @import.module)
38+
(#eq? @_require_name "require")
39+
) @import.statement
40+
41+
; ---------------------------------------------------------------------------
42+
; Calls
43+
; ---------------------------------------------------------------------------
44+
(function_call
45+
(identifier) @call.target
46+
(arguments) @call.arguments
47+
) @call.site

packages/core/src/repowise/core/ingestion/resolvers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from .generic import resolve_generic_import
1111
from .go import resolve_go_import
1212
from .kotlin import resolve_kotlin_import
13+
from .luau import resolve_luau_import
1314
from .php import resolve_php_import
1415
from .python import resolve_python_import
1516
from .ruby import resolve_ruby_import
@@ -29,6 +30,7 @@
2930
"cpp": resolve_cpp_import,
3031
"c": resolve_cpp_import,
3132
"kotlin": resolve_kotlin_import,
33+
"luau": resolve_luau_import,
3234
"ruby": resolve_ruby_import,
3335
"csharp": resolve_csharp_import,
3436
"swift": resolve_swift_import,
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
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

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ dependencies = [
4848
"tree-sitter-swift>=0.0.1",
4949
"tree-sitter-scala>=0.23,<1",
5050
"tree-sitter-php>=0.23,<1",
51+
"tree-sitter-luau>=1.2,<2",
5152
# Dependency graph
5253
"networkx>=3.3,<4",
5354
"scipy>=1.11,<2",

0 commit comments

Comments
 (0)