diff --git a/docs/LANGUAGE_SUPPORT.md b/docs/LANGUAGE_SUPPORT.md index a3a9ebb6..6f8457cc 100644 --- a/docs/LANGUAGE_SUPPORT.md +++ b/docs/LANGUAGE_SUPPORT.md @@ -88,13 +88,26 @@ endpoints or targets where applicable. | **SQL** | `.sql` | -- | | **Shell** | `.sh` `.bash` `.zsh` | -- | +### Partial (Luau — Roblox) + +| Language | Extensions | Entry Points | Import Style | +|----------|-----------|-------------|-------------| +| **Luau** | `.luau` `.lua` | `init.luau` `init.lua` | `require(script.Parent.X)` / `require(script.X)` / `require(game.Service.Path)` / `require("rel/path")` | + +AST parsing, symbol extraction (functions, Luau type aliases), and +`require(...)` call capture are wired. Import resolution handles string +literals and `script`/`script.Parent` relative instance paths. Absolute +Roblox instance paths (`game....`) currently register as external +nodes and are the target of a follow-up that reads Rojo's +`default.project.json` tree mapping — see issue #52. + ### Git-Blame-Only These languages are tracked in git history (blame, hotspot analysis, co-change detection) but have no AST parsing or dedicated support. Files appear in the wiki as traversal-level entries. -Objective-C, Elixir, Erlang, Lua, R, Dart, Zig, Julia, Clojure, Elm, +Objective-C, Elixir, Erlang, R, Dart, Zig, Julia, Clojure, Elm, Haskell, OCaml, F#, Crystal, Nim, D --- diff --git a/packages/core/src/repowise/core/ingestion/languages/registry.py b/packages/core/src/repowise/core/ingestion/languages/registry.py index 56db45e3..933b904b 100644 --- a/packages/core/src/repowise/core/ingestion/languages/registry.py +++ b/packages/core/src/repowise/core/ingestion/languages/registry.py @@ -974,10 +974,44 @@ is_passthrough=True, ), LanguageSpec( - tag="lua", - display_name="Lua", - extensions=frozenset({".lua"}), - is_passthrough=True, + tag="luau", + display_name="Luau", + # Rojo treats both .lua and .luau as Luau modules. Luau's grammar is + # a superset of Lua 5.1, so vanilla Lua files parse cleanly too. + extensions=frozenset({".lua", ".luau"}), + grammar_package="tree_sitter_luau", + scm_file="luau.scm", + heritage_node_types=frozenset(), + entry_point_patterns=("init.luau", "init.lua"), + manifest_files=("default.project.json", "wally.toml", ".rojo.json"), + blocked_dirs=("Packages", "ServerPackages", "DevPackages"), + builtin_calls=frozenset( + { + "print", + "warn", + "error", + "assert", + "pcall", + "xpcall", + "select", + "type", + "typeof", + "tonumber", + "tostring", + "ipairs", + "pairs", + "next", + "rawget", + "rawset", + "rawequal", + "rawlen", + "setmetatable", + "getmetatable", + "unpack", + "require", + } + ), + color_hex="#00A2FF", ), LanguageSpec( tag="r", diff --git a/packages/core/src/repowise/core/ingestion/models.py b/packages/core/src/repowise/core/ingestion/models.py index 71691d14..ca7dc242 100644 --- a/packages/core/src/repowise/core/ingestion/models.py +++ b/packages/core/src/repowise/core/ingestion/models.py @@ -33,6 +33,7 @@ "swift", "kotlin", "scala", + "luau", "shell", "yaml", "json", diff --git a/packages/core/src/repowise/core/ingestion/parser.py b/packages/core/src/repowise/core/ingestion/parser.py index deb214d8..fde0f2fa 100644 --- a/packages/core/src/repowise/core/ingestion/parser.py +++ b/packages/core/src/repowise/core/ingestion/parser.py @@ -413,6 +413,18 @@ class LanguageConfig: ), entry_point_patterns=["index.php", "public/index.php"], ), + "luau": LanguageConfig( + symbol_node_types={ + "function_declaration": "function", + "type_definition": "type_alias", + }, + import_node_types=["function_call"], + export_node_types=[], + visibility_fn=public_by_default, + parent_extraction="none", + parent_class_types=frozenset(), + entry_point_patterns=["init.luau", "init.lua"], + ), } diff --git a/packages/core/src/repowise/core/ingestion/queries/luau.scm b/packages/core/src/repowise/core/ingestion/queries/luau.scm new file mode 100644 index 00000000..e7170382 --- /dev/null +++ b/packages/core/src/repowise/core/ingestion/queries/luau.scm @@ -0,0 +1,47 @@ +; ============================================================================= +; repowise — Luau symbol and import queries +; tree-sitter-luau (install separately if needed) +; +; Luau is a gradually-typed superset of Lua 5.1 used by the Roblox engine. +; Rojo maps filesystem layout to Roblox instance paths via default.project.json; +; `require()` accepts instance paths such as `script.Parent.Foo`, +; `script.Foo`, or `game.ReplicatedStorage.Shared.Foo`. +; +; Full Rojo-aware import resolution lives in resolvers/luau.py; this file +; only emits symbol defs and the raw require-argument text as @import.module. +; ============================================================================= + +; Global function: function foo.bar.baz() end +(function_declaration + name: (_) @symbol.name +) @symbol.def + +; Type alias: type Foo = ... (Luau-specific; Lua 5.1 has no `type`) +(type_definition + (identifier) @symbol.name +) @symbol.def + +; --------------------------------------------------------------------------- +; Imports — captured as raw argument text for the resolver to parse. +; +; Matches: +; require("some/string/path") +; require(script.Parent.Foo) +; require(game.ReplicatedStorage.Shared.Foo) +; +; The resolver is responsible for splitting out the instance path, consulting +; Rojo's default.project.json tree, and producing a filesystem path. +; --------------------------------------------------------------------------- +(function_call + (identifier) @_require_name + (arguments (_) @import.module) + (#eq? @_require_name "require") +) @import.statement + +; --------------------------------------------------------------------------- +; Calls +; --------------------------------------------------------------------------- +(function_call + (identifier) @call.target + (arguments) @call.arguments +) @call.site diff --git a/packages/core/src/repowise/core/ingestion/resolvers/__init__.py b/packages/core/src/repowise/core/ingestion/resolvers/__init__.py index 462492e1..142b7247 100644 --- a/packages/core/src/repowise/core/ingestion/resolvers/__init__.py +++ b/packages/core/src/repowise/core/ingestion/resolvers/__init__.py @@ -10,6 +10,7 @@ from .generic import resolve_generic_import from .go import resolve_go_import from .kotlin import resolve_kotlin_import +from .luau import resolve_luau_import from .php import resolve_php_import from .python import resolve_python_import from .ruby import resolve_ruby_import @@ -29,6 +30,7 @@ "cpp": resolve_cpp_import, "c": resolve_cpp_import, "kotlin": resolve_kotlin_import, + "luau": resolve_luau_import, "ruby": resolve_ruby_import, "csharp": resolve_csharp_import, "swift": resolve_swift_import, diff --git a/packages/core/src/repowise/core/ingestion/resolvers/luau.py b/packages/core/src/repowise/core/ingestion/resolvers/luau.py new file mode 100644 index 00000000..de6ce8dd --- /dev/null +++ b/packages/core/src/repowise/core/ingestion/resolvers/luau.py @@ -0,0 +1,194 @@ +"""Luau import resolution. + +Luau's ``require(...)`` accepts four kinds of argument: + +1. String literals — ``require("./helper")`` or ``require("some/path")`` + (plain Lua style + Luau's new require-by-string). +2. Relative instance paths — ``require(script.Parent.Foo)``, + ``require(script.Foo)``, or the Rojo-safe variant + ``require(script.Parent:WaitForChild("Foo"))``. +3. Absolute Roblox instance paths — ``require(game.ReplicatedStorage.Foo)``, + where the leading service is resolved against a Rojo project's ``tree`` + mapping in ``default.project.json``. +4. ``.luaurc``-aliased requires — ``require("@dep")``, resolved by reading + ``.luaurc`` files along the directory hierarchy for an ``aliases`` map. + +This resolver handles (1) and (2). (3) requires a Rojo ``default.project.json`` +reader layered in via ``core/ingestion/dynamic_hints/rojo.py``; (4) requires a +``.luaurc`` reader analogous to the tsconfig resolver. Both are deferred to +follow-ups — see the xfail tests in ``test_luau_resolver.py``. + +Unresolved paths are intentionally *not* silently matched by filename — a +wrong edge is worse than no edge when the downstream graph feeds docs and +dead-code detection. They fall through to ``add_external_node`` so they +still appear in the graph as external references. + +Parser contract note +-------------------- +The tree-sitter query in ``queries/luau.scm`` captures the raw argument node; +``parser.py`` then normalizes the captured text with ``.strip("\"'` ")`` +before calling this function. String-literal requires therefore arrive here +*without* their surrounding quotes — e.g. ``require("./helper")`` reaches this +function as ``./helper``, not ``"./helper"``. We identify the literal branch +by process of elimination (doesn't parse as ``script.X`` or ``game.X``). +""" + +from __future__ import annotations + +import re +from pathlib import PurePosixPath + +from .context import ResolverContext + +# `script.Parent.Foo.Bar` / `script.Foo` — capture everything after the leading +# `script` so we can walk up/down from the importer. +_SCRIPT_RELATIVE = re.compile(r"^\s*script\s*((?:\.\s*\w+\s*)+)\s*$") + +# `game.....` — capture the service and the remainder. +_GAME_ABSOLUTE = re.compile(r"^\s*game\s*\.\s*(\w+)\s*((?:\.\s*\w+\s*)*)$") + +# Roblox name-lookup method calls: `:WaitForChild("Foo")` / `:FindFirstChild("Foo")`. +# These are the race-safe idioms actual Rojo code uses in place of the bare +# `.Foo` field access — on OSRPS they account for ~93% of all `require(...)` +# arguments. The name-resolution semantics are identical (look up a child of +# the preceding instance by string name), so we normalize both forms to the +# dot-chain shape before the `_SCRIPT_RELATIVE` regex runs. Optional second +# argument (timeout) is swallowed. +_INSTANCE_METHOD_CALL = re.compile( + r":\s*(?:WaitForChild|FindFirstChild)\s*\(\s*[\"']([A-Za-z_]\w*)[\"']\s*(?:,\s*[^)]+)?\)" +) + +_LUAU_SUFFIXES: tuple[str, ...] = (".luau", ".lua") + + +def _normalize_instance_methods(arg: str) -> str: + """Rewrite `:WaitForChild("Foo")` / `:FindFirstChild("Foo")` as `.Foo`. + + Roblox relative-require idioms: ``script.Parent:WaitForChild("Foo")`` + is semantically equivalent to ``script.Parent.Foo`` for module lookup + purposes (both resolve to the child instance named ``Foo``), but only + the dot form was matched by ``_SCRIPT_RELATIVE``. Normalizing up + front keeps a single regex for both shapes and avoids duplicating the + path-walking logic in ``_resolve_script_relative``. + """ + return _INSTANCE_METHOD_CALL.sub(lambda m: f".{m.group(1)}", arg) + + +def resolve_luau_import( + module_path: str, + importer_path: str, + ctx: ResolverContext, +) -> str | None: + """Resolve a Luau ``require(...)`` argument to a repo-relative file path. + + ``module_path`` is the argument text captured by ``luau.scm`` after the + parser's quote-strip pass (see module docstring). It may be a bare + filesystem path (from a string-literal require), an instance-path + expression such as ``script.Parent.Foo`` or + ``script.Parent:WaitForChild("Foo")``, or an ``@alias`` reference. + """ + raw = module_path.strip() + arg = _normalize_instance_methods(raw) + + # Relative instance path: script[.Parent]*.Name[.Name]* + # Matched against the normalized form so `:WaitForChild("Foo")` chains + # resolve the same as `.Foo` chains. Unresolved paths fall through with + # the *original* text so external-node labels reflect what was actually + # written at the call site. + m = _SCRIPT_RELATIVE.match(arg) + if m: + parts = [p.strip() for p in m.group(1).split(".") if p.strip()] + resolved = _resolve_script_relative(parts, importer_path, ctx) + if resolved is not None: + return resolved + return ctx.add_external_node(raw) + + # Absolute instance path: game..Path... + # Full Rojo-tree resolution is deferred to the Rojo follow-up; fall through + # to an external node so the graph still records the reference. + if _GAME_ABSOLUTE.match(arg): + return ctx.add_external_node(raw) + + # `.luaurc` alias: require("@dep"). Needs a luaurc reader; deferred. + if raw.startswith("@"): + return ctx.add_external_node(raw) + + # Everything else is a string-literal path. The parser has already + # stripped surrounding quotes, so `raw` is e.g. `./helper` or + # `some/path`. `_resolve_literal` handles both relative and stem-match + # resolution; unresolved literals fall through to an external node + # without any silent filename guess. + resolved = _resolve_literal(raw, importer_path, ctx) + if resolved is not None: + return resolved + return ctx.add_external_node(raw) + + +def _resolve_literal(literal: str, importer_path: str, ctx: ResolverContext) -> str | None: + """Resolve a plain string require — relative or stem match.""" + importer_dir = PurePosixPath(importer_path).parent + candidate = (importer_dir / literal).as_posix() + for suffix in _LUAU_SUFFIXES: + full = f"{candidate}{suffix}" + if full in ctx.path_set: + return full + if literal in ctx.path_set: + return literal + + stem = PurePosixPath(literal).stem.lower().replace("-", "_") + result = ctx.stem_lookup(stem) + if result and any(result.endswith(s) for s in _LUAU_SUFFIXES): + return result + return None + + +def _resolve_script_relative( + parts: list[str], importer_path: str, ctx: ResolverContext +) -> str | None: + """Walk ``Parent``/name segments relative to the importing file. + + Roblox semantics: ``script`` is the importing module instance; its + ``script.Parent`` is the *container* that holds it. For Rojo-synced + code, a ``.luau``/``.lua`` file lives inside its container directory, + so ``script.Parent`` is that directory. This means the *first* + ``Parent`` segment is an identity (we're already there); each + subsequent ``Parent`` walks one more level up. + + After the leading ``Parent`` run, any remaining identifiers descend + into child instances by name. The terminal segment resolves to either + ``.luau``/``.lua`` or a directory with + ``init.luau``/``init.lua``. + """ + here = PurePosixPath(importer_path).parent + i = 0 + # First "Parent" is a no-op — `here` already represents script.Parent. + if i < len(parts) and parts[i] == "Parent": + i += 1 + # Each subsequent "Parent" walks up one level. + while i < len(parts) and parts[i] == "Parent": + here = here.parent + i += 1 + + remainder = parts[i:] + if not remainder: + return None + + base = here + for seg in remainder[:-1]: + base = base / seg + + name = remainder[-1] + + # Module-as-file: /.luau|.lua + for suffix in _LUAU_SUFFIXES: + candidate = (base / f"{name}{suffix}").as_posix() + if candidate in ctx.path_set: + return candidate + + # Module-as-directory: //init.luau|.lua + for suffix in _LUAU_SUFFIXES: + candidate = (base / name / f"init{suffix}").as_posix() + if candidate in ctx.path_set: + return candidate + + return None diff --git a/pyproject.toml b/pyproject.toml index cf369ad6..25e6b6c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ dependencies = [ "tree-sitter-swift>=0.0.1", "tree-sitter-scala>=0.23,<1", "tree-sitter-php>=0.23,<1", + "tree-sitter-luau>=1.2,<2", # Dependency graph "networkx>=3.3,<4", "scipy>=1.11,<2", diff --git a/tests/unit/ingestion/test_luau_resolver.py b/tests/unit/ingestion/test_luau_resolver.py new file mode 100644 index 00000000..78807c33 --- /dev/null +++ b/tests/unit/ingestion/test_luau_resolver.py @@ -0,0 +1,184 @@ +"""Unit tests for the Luau import resolver. + +Covers the two resolution modes implemented in this PR: + +- String-literal requires (``require("relative/path")``) +- ``script`` / ``script.Parent`` relative instance paths + +The ``game..Path`` absolute form requires Rojo project JSON and is +explicitly deferred to a follow-up (issue #52); see the xfail case. + +Parser contract +--------------- +The arguments passed to ``resolve_luau_import`` mirror what the production +parser emits: ``parser.py`` strips surrounding quotes from the captured +``@import.module`` text (``.strip("\"'` ")``) before calling the resolver. +String-literal tests therefore pass *unquoted* paths (``"./helper"`` becomes +``./helper``) — passing a quoted string here would not reflect the real +production handoff. +""" + +from __future__ import annotations + +import networkx as nx +import pytest + +from repowise.core.ingestion.resolvers.context import ResolverContext +from repowise.core.ingestion.resolvers.luau import resolve_luau_import + + +def _ctx(paths: set[str]) -> ResolverContext: + stem_map: dict[str, list[str]] = {} + for p in paths: + stem = p.rsplit("/", 1)[-1].rsplit(".", 1)[0].lower() + stem_map.setdefault(stem, []).append(p) + return ResolverContext( + path_set=paths, + stem_map=stem_map, + graph=nx.DiGraph(), + ) + + +class TestScriptRelative: + def test_sibling_via_parent(self) -> None: + ctx = _ctx({"src/shared/Signal.luau", "src/client/main.luau"}) + got = resolve_luau_import("script.Parent.Signal", "src/client/main.luau", ctx) + # script.Parent == src/client, so the sibling is src/client/Signal.luau + # -- this case has no match. script.Parent.Parent.shared.Signal would. + # The resolver should return external rather than wrong-file match. + assert got == "external:script.Parent.Signal" + + def test_child_module(self) -> None: + ctx = _ctx({"src/shared/util/init.luau", "src/shared/util/Signal.luau"}) + got = resolve_luau_import("script.Signal", "src/shared/util/init.luau", ctx) + assert got == "src/shared/util/Signal.luau" + + def test_parent_walks_up(self) -> None: + ctx = _ctx({"src/shared/Signal.luau", "src/client/controllers/main.luau"}) + got = resolve_luau_import( + "script.Parent.Parent.Parent.shared.Signal", + "src/client/controllers/main.luau", + ctx, + ) + assert got == "src/shared/Signal.luau" + + def test_module_as_directory(self) -> None: + ctx = _ctx({"src/shared/util/init.lua", "src/shared/main.luau"}) + got = resolve_luau_import("script.Parent.util", "src/shared/main.luau", ctx) + assert got == "src/shared/util/init.lua" + + +class TestScriptRelativeWithInstanceMethods: + # Rojo-safe idioms — on OSRPS these account for ~93% of `require(...)`. + # They must resolve identically to the dot-chain forms in TestScriptRelative. + + def test_wait_for_child_child_module(self) -> None: + ctx = _ctx({"src/shared/util/init.luau", "src/shared/util/Signal.luau"}) + got = resolve_luau_import('script:WaitForChild("Signal")', "src/shared/util/init.luau", ctx) + assert got == "src/shared/util/Signal.luau" + + def test_wait_for_child_mixed_with_parent(self) -> None: + ctx = _ctx({"src/shared/Signal.luau", "src/client/main.luau"}) + got = resolve_luau_import( + 'script.Parent.Parent:WaitForChild("shared"):WaitForChild("Signal")', + "src/client/main.luau", + ctx, + ) + assert got == "src/shared/Signal.luau" + + def test_find_first_child_sibling(self) -> None: + ctx = _ctx({"src/shared/util/init.luau", "src/shared/util/Signal.luau"}) + got = resolve_luau_import( + 'script:FindFirstChild("Signal")', "src/shared/util/init.luau", ctx + ) + assert got == "src/shared/util/Signal.luau" + + def test_wait_for_child_with_timeout_arg(self) -> None: + # Roblox `WaitForChild(name, timeoutSeconds)` — timeout is discarded. + ctx = _ctx({"src/shared/util/init.luau", "src/shared/util/Signal.luau"}) + got = resolve_luau_import( + 'script:WaitForChild("Signal", 5)', "src/shared/util/init.luau", ctx + ) + assert got == "src/shared/util/Signal.luau" + + def test_unresolved_wait_for_child_preserves_original_text(self) -> None: + # The graph's external-node label should match what the user wrote, + # not the post-normalization form — readers shouldn't see a rewritten + # `.Foo` when their code says `:WaitForChild("Foo")`. + ctx = _ctx(set()) + got = resolve_luau_import('script.Parent:WaitForChild("Missing")', "src/a.luau", ctx) + assert got == 'external:script.Parent:WaitForChild("Missing")' + + +class TestStringLiteral: + # The parser strips quotes at parser.py:705 before the resolver runs, + # so every input below is unquoted — matching production. An earlier + # version of this test class passed *quoted* strings, which masked a + # real bug: the resolver's string-literal branch was unreachable in + # production and every `require("…")` landed on the external fallback. + + def test_relative_string(self) -> None: + ctx = _ctx({"src/shared/helper.luau", "src/shared/main.luau"}) + got = resolve_luau_import("./helper", "src/shared/main.luau", ctx) + assert got == "src/shared/helper.luau" + + def test_parent_relative_string(self) -> None: + ctx = _ctx({"bench/bench_support.lua", "bench/gc/test_foo.lua"}) + got = resolve_luau_import("../bench_support", "bench/gc/test_foo.lua", ctx) + assert got == "bench/bench_support.lua" + + def test_stem_match_bare_path(self) -> None: + ctx = _ctx({"src/shared/helper.luau", "src/main.luau"}) + got = resolve_luau_import("helper", "src/main.luau", ctx) + assert got == "src/shared/helper.luau" + + def test_unresolved_string_goes_external(self) -> None: + ctx = _ctx(set()) + got = resolve_luau_import("nowhere", "src/a.luau", ctx) + assert got == "external:nowhere" + + def test_luaurc_alias_is_external_until_follow_up(self) -> None: + # `.luaurc` `@alias` resolution is deferred — see + # TestLuaurcAlias.test_alias_resolves_via_luaurc_follow_up. + ctx = _ctx({"src/dependency.luau"}) + got = resolve_luau_import("@dep", "src/main.luau", ctx) + assert got == "external:@dep" + + +class TestAbsoluteInstancePath: + @pytest.mark.xfail( + reason="Rojo default.project.json-aware resolution — issue #52 follow-up.", + strict=True, + ) + def test_game_replicated_storage_resolves_via_rojo_tree(self) -> None: + """Expected end state: given a Rojo project whose tree maps + ``ReplicatedStorage.Shared`` to ``src/shared``, a require of + ``game.ReplicatedStorage.Shared.Util`` resolves to + ``src/shared/Util.luau``. + """ + ctx = _ctx({"src/shared/Util.luau"}) + got = resolve_luau_import("game.ReplicatedStorage.Shared.Util", "src/client/main.luau", ctx) + assert got == "src/shared/Util.luau" + + +class TestLuaurcAlias: + @pytest.mark.xfail( + reason=".luaurc alias-aware resolution — second Luau follow-up, parallel to Rojo.", + strict=True, + ) + def test_alias_resolves_via_luaurc_follow_up(self) -> None: + """Expected end state: given a ``.luaurc`` alongside the importer + declaring ``{"aliases": {"dep": "./dependency"}}``, a require of + ``@dep`` resolves to ``src/dependency.luau``. + + Counts from running `repowise init` against the upstream + luau-lang/luau repo: 24 `@alias` requires — every one of them + currently lands on the external-node fallback. Implementing this + needs a ``.luaurc`` reader layered in via + ``core/ingestion/dynamic_hints/luaurc.py`` (analogous to the Rojo + reader), with lookup walking parent directories and child + ``.luaurc`` files overriding parent aliases. + """ + ctx = _ctx({"src/dependency.luau"}) + got = resolve_luau_import("@dep", "src/main.luau", ctx) + assert got == "src/dependency.luau" diff --git a/tests/unit/ingestion/test_parser.py b/tests/unit/ingestion/test_parser.py index 3462b8d0..60b8b096 100644 --- a/tests/unit/ingestion/test_parser.py +++ b/tests/unit/ingestion/test_parser.py @@ -1011,12 +1011,49 @@ def test_no_parse_errors(self, parser: ASTParser) -> None: assert result.parse_errors == [] +# --------------------------------------------------------------------------- +# Luau (issue #52 — Roblox/Rojo support) +# --------------------------------------------------------------------------- + +LUAU_SOURCE = b"""-- Module docstring placeholder. + +local Signal = require(script.Parent.Signal) +local Shared = require(game.ReplicatedStorage.Shared.Util) + +type Callback = (value: number) -> () + +function greet(name: string): string + return "hello " .. name +end + +function Calculator:add(x: number, y: number): number + return x + y +end +""" + + +class TestLuauParser: + def test_finds_top_level_function(self, parser: ASTParser) -> None: + fi = _make_file_info("luau_pkg/init.luau", "luau") + result = parser.parse_file(fi, LUAU_SOURCE) + names = [s.name for s in result.symbols] + assert "greet" in names + + def test_parses_require_imports(self, parser: ASTParser) -> None: + fi = _make_file_info("luau_pkg/init.luau", "luau") + result = parser.parse_file(fi, LUAU_SOURCE) + raw = [i.module_path for i in result.imports] + # The .scm emits the raw argument text; the resolver interprets it. + assert any("Signal" in m for m in raw) + assert any("ReplicatedStorage" in m for m in raw) + + class TestLanguageConfigs: def test_all_supported_languages_have_config(self) -> None: expected = { "python", "typescript", "javascript", "go", "rust", "java", "cpp", "c", "kotlin", "ruby", "csharp", "swift", - "scala", "php", + "scala", "php", "luau", } for lang in expected: assert lang in LANGUAGE_CONFIGS, f"Missing config for {lang}" diff --git a/uv.lock b/uv.lock index 3b737060..303374e1 100644 --- a/uv.lock +++ b/uv.lock @@ -3044,6 +3044,7 @@ dependencies = [ { name = "tree-sitter-java" }, { name = "tree-sitter-javascript" }, { name = "tree-sitter-kotlin" }, + { name = "tree-sitter-luau" }, { name = "tree-sitter-php" }, { name = "tree-sitter-python" }, { name = "tree-sitter-ruby" }, @@ -3132,6 +3133,7 @@ requires-dist = [ { name = "tree-sitter-java", specifier = ">=0.23,<1" }, { name = "tree-sitter-javascript", specifier = ">=0.23,<1" }, { name = "tree-sitter-kotlin", specifier = ">=1,<2" }, + { name = "tree-sitter-luau", specifier = ">=1.2,<2" }, { name = "tree-sitter-php", specifier = ">=0.23,<1" }, { name = "tree-sitter-python", specifier = ">=0.23,<1" }, { name = "tree-sitter-ruby", specifier = ">=0.23,<1" }, @@ -4000,6 +4002,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/b9/12fa97f63d2b7517c6f5d16938f0c5bfe84d925c652c75ff1c5e29bf6a44/tree_sitter_kotlin-1.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:e030f127a7d07952907adb9070248bd42fb86dc76fd92744727551b50e131ee7", size = 310414, upload-time = "2025-01-09T19:02:16.23Z" }, ] +[[package]] +name = "tree-sitter-luau" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/21/f7e870dd4d6357f5eea5a473a327c535951b5f32c408939b247cbd8ad388/tree_sitter_luau-1.2.0.tar.gz", hash = "sha256:025f473379a2895f17bc4d24325b3f3e5c90ed908f6642d15e78edb494a60486", size = 46995, upload-time = "2024-12-22T22:42:47.581Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/c4/9e723f69ec3698aebe241ca89b07a8751c35f8713c56e448c4abcc11c6b8/tree_sitter_luau-1.2.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:c365dd21a8056046378c6a006cf7149291facb143ca9e00bc51c5c1f1fcdc344", size = 30689, upload-time = "2024-12-22T22:42:34.22Z" }, + { url = "https://files.pythonhosted.org/packages/1b/2b/1746da1b669398b86454cb3e99bd5d3aa51a11ac8d3e99ab6f6cb592ae45/tree_sitter_luau-1.2.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:b699edbbe4bbe4b3a9a1da42acb3b4c98c760a6e3b5ceedaf0d44487dc1a26cc", size = 31727, upload-time = "2024-12-22T22:42:36.55Z" }, + { url = "https://files.pythonhosted.org/packages/fc/95/bc169e4f5879e3b37b95d1ee753744e9408912ffde87c409c23e99bcff62/tree_sitter_luau-1.2.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf311778d884ae744571c12344edbcfe0a6351c482a7710c58fedeae4a2c6dfd", size = 53654, upload-time = "2024-12-22T22:42:38.492Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b3/d99f00230812ea2c1c4b53a68de4c38f3c8f7097e898d26cb2e76525d4bb/tree_sitter_luau-1.2.0-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb35eb808d6a51d30aabfa2845bcb1cce7bce63599bdd4c1faba4df5e2dfb4f", size = 52722, upload-time = "2024-12-22T22:42:40.556Z" }, + { url = "https://files.pythonhosted.org/packages/ac/53/33b3ceb6c51757db94a2f12764bef2dfca003593f3c5663d46f5b0bc12bc/tree_sitter_luau-1.2.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8e2a35014902028c312dff6d45439fbdb8255583104a9e1c0f286162527629b2", size = 51127, upload-time = "2024-12-22T22:42:41.68Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7e/5379a5502835f7bf39084cefd6dca6e5b7ed12a9c0ef692a57962e33ea38/tree_sitter_luau-1.2.0-cp39-abi3-win_amd64.whl", hash = "sha256:c8f86b022fe6d1a784e26fa2e898f1459a3f4d31555bed31bf6dda9c72e46f6d", size = 32942, upload-time = "2024-12-22T22:42:43.676Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/db574405f592f1f4f039dc83db8545d634b68481ce0c457bbab26e155a4a/tree_sitter_luau-1.2.0-cp39-abi3-win_arm64.whl", hash = "sha256:37e9a2e2dd9795800c98c160eabcfed9a90a25a80e2f207658902b0444a40440", size = 31300, upload-time = "2024-12-22T22:42:45.64Z" }, +] + [[package]] name = "tree-sitter-php" version = "0.24.1"