Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion docs/LANGUAGE_SUPPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<Service>...`) 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

---
Expand Down
42 changes: 38 additions & 4 deletions packages/core/src/repowise/core/ingestion/languages/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/repowise/core/ingestion/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"swift",
"kotlin",
"scala",
"luau",
"shell",
"yaml",
"json",
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/repowise/core/ingestion/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
),
}


Expand Down
47 changes: 47 additions & 0 deletions packages/core/src/repowise/core/ingestion/queries/luau.scm
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
194 changes: 194 additions & 0 deletions packages/core/src/repowise/core/ingestion/resolvers/luau.py
Original file line number Diff line number Diff line change
@@ -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.<Service>.<Path>...` — 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.<Service>.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
``<name>.luau``/``<name>.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: <base>/<name>.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: <base>/<name>/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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading