Something went wrong. Try again.
A native-speed rewrite of the roblox-ts compiler in golang
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384// Package sourcemap generates Rojo-compatible sourcemap.json files — the// format `rojo sourcemap --include-non-scripts` emits and luau-lsp consumes —// directly from a Rojo project file, without running rojo for the common// script-tree subset.//// Output parity was verified against Rojo 7.6.1: compact JSON with key order// name/className/filePaths/children, empty filePaths/children omitted, a// trailing newline, and file paths relative to the project file's directory// with forward slashes. Child ordering matches rojo: children synced from a// $path directory come first (name-sorted), then project-declared children in// byte-sorted order (rojo keeps project node children in a BTreeMap). Only the// root node carries the project file in filePaths, appended after any// $path-derived files.//// Projects using features outside the native subset (globIgnorePaths,// .json/.toml/.csv/.model.json/.meta.json/.rbxm/.rbxmx files, nested projects,// or $path entries missing from disk) fall back to running `rojo sourcemap`// when rojo is on PATH, mirroring internal/pack.package sourcemap
import ( "bytes" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "slices" "sort" "strings"
"rotor/internal/rojo")
// Node is one instance in a Rojo sourcemap. Field order and the omitempty// rules mirror rojo's serialization exactly.type Node struct { Name string `json:"name"` ClassName string `json:"className"` FilePaths []string `json:"filePaths,omitempty"` Children []*Node `json:"children,omitempty"`}
// Generate produces sourcemap bytes for a project (a *.project.json file, or a// directory containing one; "" means "."). It builds the tree natively when// the project is within the supported subset, falling back to// `rojo sourcemap --include-non-scripts` otherwise.func Generate(project string) ([]byte, error) { projectFile, err := resolveProject(project) if err != nil { return nil, err } root, supported, err := Build(projectFile) if err != nil { return nil, err } if !supported { return generateViaRojo(projectFile) } return Marshal(root)}
// Marshal encodes a sourcemap tree the way rojo does: compact JSON, no HTML// escaping, trailing newline.func Marshal(root *Node) ([]byte, error) { var buf bytes.Buffer enc := json.NewEncoder(&buf) enc.SetEscapeHTML(false) if err := enc.Encode(root); err != nil { return nil, err } return buf.Bytes(), nil}
// Build constructs the sourcemap tree natively from a Rojo project file.// supported reports whether the project is within the native subset; when it// is false the returned tree is incomplete and the caller should fall back to// `rojo sourcemap`.func Build(projectFile string) (root *Node, supported bool, err error) { name, tree, err := rojo.ParseProjectFile(projectFile) if err != nil { return nil, false, err } b := &builder{base: filepath.Dir(projectFile), supported: true} if hasGlobIgnorePaths(projectFile) { // rojo filters synced files through these globs; reproduce via rojo. b.supported = false } root = b.fromTree(name, tree) // Only the root carries the project file, appended after $path files. root.FilePaths = append(root.FilePaths, b.rel(projectFile)) return root, b.supported, nil}
// hasGlobIgnorePaths reports whether the project file declares a non-empty// globIgnorePaths array (a feature the native builder does not model).func hasGlobIgnorePaths(projectFile string) bool { data, err := os.ReadFile(projectFile) if err != nil { return true // be conservative; rojo will surface the real error } var meta struct { GlobIgnorePaths []string `json:"globIgnorePaths"` } if json.Unmarshal(data, &meta) != nil { return false } return len(meta.GlobIgnorePaths) > 0}
type builder struct { base string supported bool}
func (b *builder) unsupported() { b.supported = false }
// rel converts an absolute-or-project-joined path into the project-relative,// forward-slash form rojo emits.func (b *builder) rel(fsPath string) string { r, err := filepath.Rel(b.base, fsPath) if err != nil { return filepath.ToSlash(fsPath) } return filepath.ToSlash(r)}
// fromTree builds a node named name from a Rojo project tree node.func (b *builder) fromTree(name string, tree *rojo.Tree) *Node { n := &Node{Name: name, ClassName: tree.ClassName} if tree.Path != nil { b.applyPath(n, filepath.Join(b.base, *tree.Path)) } // Project-declared children come after $path-derived ones, byte-sorted by // name (rojo stores project node children in a BTreeMap). declared := slices.Clone(tree.Children) sort.SliceStable(declared, func(i, j int) bool { return declared[i].Name < declared[j].Name }) for _, child := range declared { n.Children = append(n.Children, b.fromTree(child.Name, child.Tree)) } if n.ClassName == "" { if tree.Path == nil { // $className is optional when the node's name is itself a class: // services under DataModel, StarterPlayerScripts under // StarterPlayer, and so on. Rojo errors on names it cannot // resolve, so for valid projects the name is the class. n.ClassName = name } else { n.ClassName = "Folder" } } return n}
// applyPath fills a node's class/filePaths/children from a filesystem path,// without changing its Name.func (b *builder) applyPath(n *Node, fsPath string) { info, err := os.Stat(fsPath) if err != nil { // Missing or unreadable $path: possibly optional, possibly not built // yet. Let rojo decide what it means. b.unsupported() return } if !info.IsDir() { cn, ok := classifyClass(fsPath) if !ok { b.unsupported() return } if n.ClassName == "" { n.ClassName = cn } n.FilePaths = append(n.FilePaths, b.rel(fsPath)) return } b.applyDir(n, fsPath)}
// applyDir handles a directory: an init script (making the directory a script)// or a Folder, plus the directory's entries as children in name-sorted order.func (b *builder) applyDir(n *Node, dir string) { entries, err := os.ReadDir(dir) if err != nil { b.unsupported() return } names := make([]string, 0, len(entries)) for _, e := range entries { names = append(names, e.Name()) } sort.Strings(names)
initFile, initClass := findInit(names) if initFile != "" { if n.ClassName == "" { n.ClassName = initClass } n.FilePaths = append(n.FilePaths, b.rel(filepath.Join(dir, initFile))) } else if n.ClassName == "" { n.ClassName = "Folder" }
for _, name := range names { if name == initFile || isIgnored(name) { continue } full := filepath.Join(dir, name) info, err := os.Stat(full) if err != nil { b.unsupported() continue } if info.IsDir() { child := &Node{Name: name} b.applyDir(child, full) n.Children = append(n.Children, child) continue } lower := strings.ToLower(name) switch { case strings.HasSuffix(lower, ".project.json"): b.unsupported() // nested project case strings.HasSuffix(lower, ".meta.json"): b.unsupported() // can change sibling/parent classes case strings.HasSuffix(lower, ".model.json"), strings.HasSuffix(lower, ".json"), strings.HasSuffix(lower, ".toml"), strings.HasSuffix(lower, ".csv"), strings.HasSuffix(lower, ".rbxm"), strings.HasSuffix(lower, ".rbxmx"): b.unsupported() // rojo middleware the native builder doesn't model default: cn, ok := classifyClass(full) if !ok { continue // rojo silently ignores unknown file types } n.Children = append(n.Children, &Node{ Name: deriveName(name), ClassName: cn, FilePaths: []string{b.rel(full)}, }) } }}
// generateViaRojo shells out to `rojo sourcemap --include-non-scripts`, whose// output (paths relative to the project file's directory, forward slashes,// trailing newline) matches the native builder's.func generateViaRojo(projectFile string) ([]byte, error) { rojoBin, err := exec.LookPath("rojo") if err != nil { return nil, fmt.Errorf("this project uses features beyond the native sourcemap subset " + "(globIgnorePaths, .json/.toml/.csv/.model.json/.meta.json/.rbxm files, nested projects, " + "or $path entries that do not exist yet) and needs `rojo sourcemap`, but rojo is not on PATH") } cmd := exec.Command(rojoBin, "sourcemap", "--include-non-scripts", projectFile) var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { return nil, fmt.Errorf("rojo sourcemap failed: %v\n%s", err, stderr.Bytes()) } return out, nil}
// resolveProject resolves a project argument (file, directory, or "") to a// *.project.json path, mirroring internal/pack.func resolveProject(p string) (string, error) { if p == "" { p = "." } info, err := os.Stat(p) if err != nil { return "", fmt.Errorf("project path %q: %w", p, err) } if !info.IsDir() { return p, nil } if def := filepath.Join(p, "default.project.json"); isFile(def) { return def, nil } matches, _ := filepath.Glob(filepath.Join(p, "*.project.json")) if len(matches) > 0 { return matches[0], nil } return "", fmt.Errorf("no *.project.json found in %s", p)}
func isFile(path string) bool { info, err := os.Stat(path) return err == nil && !info.IsDir()}
// findInit returns the init script file name in a directory (if any) and its// class, with the same precedence internal/pack uses.func findInit(names []string) (file, class string) { for _, candidate := range []struct { file, class string }{ {"init.luau", "ModuleScript"}, {"init.lua", "ModuleScript"}, {"init.server.luau", "Script"}, {"init.server.lua", "Script"}, {"init.client.luau", "LocalScript"}, {"init.client.lua", "LocalScript"}, } { if slices.Contains(names, candidate.file) { return candidate.file, candidate.class } } return "", ""}
// classifyClass returns the instance class for a file the native subset// models: .luau/.lua scripts (honoring .server/.client sub-extensions) and// .txt StringValues. ok is false for anything else.func classifyClass(fsPath string) (className string, ok bool) { if cn := scriptClass(fsPath); cn != "" { return cn, true } if strings.HasSuffix(strings.ToLower(fsPath), ".txt") { return "StringValue", true } return "", false}
// scriptClass returns the script class for a .luau/.lua path (honoring// .server/.client sub-extensions), or "" if the file is not a script.func scriptClass(fsPath string) string { lower := strings.ToLower(fsPath) stem, ok := trimLuaExt(lower) if !ok { return "" } switch { case strings.HasSuffix(stem, ".server"): return "Script" case strings.HasSuffix(stem, ".client"): return "LocalScript" default: return "ModuleScript" }}
func trimLuaExt(lower string) (string, bool) { if stem, ok := strings.CutSuffix(lower, ".luau"); ok { return stem, true } if stem, ok := strings.CutSuffix(lower, ".lua"); ok { return stem, true } return "", false}
// deriveName strips the recognized extension and sub-extension from a base// file name, preserving the original casing of the remaining stem.func deriveName(base string) string { lower := strings.ToLower(base) if stem, ok := trimLuaExt(lower); ok { n := len(base) - (len(lower) - len(stem)) out := base[:n] for _, sub := range []string{".server", ".client"} { if strings.HasSuffix(strings.ToLower(out), sub) { out = out[:len(out)-len(sub)] break } } return out } if strings.HasSuffix(lower, ".txt") { return base[:len(base)-len(".txt")] } return base}
func isIgnored(name string) bool { switch name { case ".DS_Store", "Thumbs.db", ".git": return true } return false}