cluaupp 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +12 -7
  3. package/bin/cluau.js +1 -1
  4. package/bin/cluaupp.js +1 -1
  5. package/docs/README.md +1 -1
  6. package/docs/architecture.md +52 -77
  7. package/docs/cli.md +29 -9
  8. package/docs/comparison.md +3 -3
  9. package/docs/config.md +6 -6
  10. package/docs/cpp-organization.md +2 -2
  11. package/docs/examples/index.md +2 -0
  12. package/docs/getting-started.md +7 -5
  13. package/docs/intellisense.md +49 -31
  14. package/docs/intro.md +2 -2
  15. package/docs/oop/file-tags.md +16 -17
  16. package/docs/oop/index.md +2 -2
  17. package/docs/oop/modules.md +3 -1
  18. package/docs/oop/services.md +7 -14
  19. package/docs/syntax.md +6 -5
  20. package/editors/vscode/extension.js +97 -146
  21. package/editors/vscode/package.json +6 -6
  22. package/examples/README.md +18 -0
  23. package/examples/game/.clangd +25 -0
  24. package/examples/game/.vscode/c_cpp_properties.json +27 -0
  25. package/examples/game/.vscode/extensions.json +5 -0
  26. package/examples/game/.vscode/settings.json +27 -0
  27. package/examples/game/Packages/.gitkeep +0 -0
  28. package/examples/game/cluaupp.config.json +6 -0
  29. package/examples/game/compile_flags.txt +6 -0
  30. package/examples/game/default.project.json +39 -0
  31. package/examples/game/package.json +9 -0
  32. package/examples/game/rokit.toml +5 -0
  33. package/examples/game/src/client/hud.client.cpp +12 -0
  34. package/{src → examples/game/src}/client/init.client.cpp +1 -0
  35. package/examples/game/src/server/combat.server.cpp +23 -0
  36. package/{src → examples/game/src}/server/leaderstats.server.cpp +1 -0
  37. package/examples/game/wally.toml +11 -0
  38. package/generated/api.js +51 -0
  39. package/generated/architecture.js +473 -0
  40. package/generated/ast.js +2 -0
  41. package/generated/cli.js +191 -0
  42. package/generated/compile.js +115 -0
  43. package/generated/editor-install.js +170 -0
  44. package/generated/emit.js +503 -0
  45. package/generated/emitter/luau-codegen.js +167 -0
  46. package/generated/emitter/translators.js +164 -0
  47. package/generated/headers.js +220 -0
  48. package/generated/index.html +49 -0
  49. package/generated/intellisense.js +277 -0
  50. package/generated/layout.js +112 -0
  51. package/generated/lex.js +146 -0
  52. package/generated/libs.js +484 -0
  53. package/generated/lsp.js +55 -0
  54. package/generated/package-info.js +11 -0
  55. package/generated/parse.js +607 -0
  56. package/generated/parser/collector.js +100 -0
  57. package/generated/parser/index.js +30 -0
  58. package/generated/preprocess.js +181 -0
  59. package/generated/system-understander.js +443 -0
  60. package/generated/transpile.js +66 -0
  61. package/generated/types.js +2 -0
  62. package/generated/understand.js +298 -0
  63. package/generated/utils/process-orchestrator.js +63 -0
  64. package/generated/utils/project.js +491 -0
  65. package/generated/utils/rojo-mapper.js +181 -0
  66. package/generated/utils/safe-paths.js +104 -0
  67. package/package.json +25 -8
  68. package/src/api.ts +53 -0
  69. package/src/{architecture.js → architecture.ts} +80 -33
  70. package/src/ast.ts +37 -0
  71. package/src/cli.ts +214 -0
  72. package/src/compile.ts +118 -0
  73. package/src/editor-install.ts +182 -0
  74. package/src/{emit.js → emit.ts} +5 -6
  75. package/src/emitter/luau-codegen.ts +187 -0
  76. package/src/emitter/translators.ts +168 -0
  77. package/src/headers.ts +233 -0
  78. package/src/intellisense.ts +286 -0
  79. package/src/{layout.js → layout.ts} +114 -129
  80. package/src/{lex.js → lex.ts} +4 -6
  81. package/src/{libs.js → libs.ts} +119 -46
  82. package/src/lsp.ts +60 -0
  83. package/src/package-info.ts +7 -0
  84. package/src/{parse.js → parse.ts} +3 -4
  85. package/src/parser/collector.ts +115 -0
  86. package/src/parser/index.ts +27 -0
  87. package/src/{preprocess.js → preprocess.ts} +64 -32
  88. package/src/system-understander.ts +501 -0
  89. package/src/transpile.ts +64 -0
  90. package/src/tree-sitter-cpp.d.ts +4 -0
  91. package/src/types.ts +81 -0
  92. package/src/{understand.js → understand.ts} +24 -213
  93. package/src/utils/process-orchestrator.ts +72 -0
  94. package/src/utils/project.ts +506 -0
  95. package/src/utils/rojo-mapper.ts +190 -0
  96. package/src/utils/safe-paths.ts +98 -0
  97. package/templates/game/.clangd +9 -0
  98. package/templates/game/.vscode/c_cpp_properties.json +1 -1
  99. package/templates/game/.vscode/extensions.json +5 -6
  100. package/templates/game/.vscode/settings.json +7 -6
  101. package/templates/game/cluaupp.config.json +2 -2
  102. package/templates/game/compile_flags.txt +1 -0
  103. package/templates/game/rokit.toml +5 -0
  104. package/templates/game/src/client/init.client.cpp +1 -0
  105. package/templates/game/src/server/leaderstats.server.cpp +1 -0
  106. package/src/api.js +0 -57
  107. package/src/cli.js +0 -481
  108. package/src/compile.js +0 -56
  109. package/src/editor-install.js +0 -218
  110. package/src/intellisense.js +0 -1368
  111. package/src/lsp.js +0 -227
  112. /package/{src → examples/game/src}/shared/config.cpp +0 -0
  113. /package/{src → examples/game/src}/shared/config.h +0 -0
@@ -0,0 +1,506 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import type { BuildOptions, BuildResult, ProjectConfig } from "../types.js";
4
+ import { pkg } from "../package-info.js";
5
+ import { isSourceFile, toLuauPath } from "../preprocess.js";
6
+ import { installEditorSupport, syncEditorSupport } from "../intellisense.js";
7
+ import { ProcessOrchestrator } from "./process-orchestrator.js";
8
+ import { RojoMapper } from "./rojo-mapper.js";
9
+ import { transpileSource } from "../transpile.js";
10
+ import { assertInitDest, assertInside, isInside, safeProjectSubdir, safeRelPath } from "./safe-paths.js";
11
+
12
+ function readProjectConfig(file: string): Partial<ProjectConfig> {
13
+ const raw = JSON.parse(fs.readFileSync(file, "utf8"));
14
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
15
+ return {};
16
+ }
17
+ const data = raw as Record<string, unknown>;
18
+ const parsed: Partial<ProjectConfig> = {};
19
+ if (typeof data.rootDir === "string") {
20
+ parsed.rootDir = data.rootDir;
21
+ }
22
+ if (typeof data.outDir === "string") {
23
+ parsed.outDir = data.outDir;
24
+ }
25
+ if (typeof data.strict === "boolean") {
26
+ parsed.strict = data.strict;
27
+ }
28
+ if (typeof data.architecture === "boolean") {
29
+ parsed.architecture = data.architecture;
30
+ }
31
+ return parsed;
32
+ }
33
+
34
+ export function loadConfig(root: string): ProjectConfig {
35
+ const defaults: ProjectConfig = { rootDir: "src", outDir: "out", strict: false, architecture: false };
36
+ const configNames = ["cluaupp.config.json", "cluau.config.json"];
37
+ let parsed: Partial<ProjectConfig> = {};
38
+ for (const name of configNames) {
39
+ const file = path.join(root, name);
40
+ if (fs.existsSync(file)) {
41
+ parsed = readProjectConfig(file);
42
+ break;
43
+ }
44
+ }
45
+ const config: ProjectConfig = {
46
+ ...defaults,
47
+ ...parsed,
48
+ rootDir: safeProjectSubdir(parsed.rootDir || defaults.rootDir, defaults.rootDir, "rootDir"),
49
+ outDir: safeProjectSubdir(parsed.outDir || defaults.outDir, defaults.outDir, "outDir"),
50
+ };
51
+ if (config.rootDir === config.outDir) {
52
+ throw new Error("cluaupp: rootDir and outDir must be different");
53
+ }
54
+ const absRoot = path.resolve(root);
55
+ assertInside(absRoot, path.join(absRoot, config.rootDir), "rootDir");
56
+ assertInside(absRoot, path.join(absRoot, config.outDir), "outDir");
57
+ return config;
58
+ }
59
+
60
+ function copyDir(from: string, to: string): void {
61
+ fs.mkdirSync(to, { recursive: true });
62
+ for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
63
+ const src = path.join(from, entry.name);
64
+ const dest = path.join(to, entry.name);
65
+ if (entry.isDirectory()) {
66
+ copyDir(src, dest);
67
+ } else {
68
+ fs.copyFileSync(src, dest);
69
+ }
70
+ }
71
+ }
72
+
73
+ function tryRm(target: string, root?: string): boolean {
74
+ if (root && !isInside(root, target)) {
75
+ console.error("cluaupp: skip remove outside project", target);
76
+ return false;
77
+ }
78
+ try {
79
+ fs.rmSync(target, { recursive: true, force: true });
80
+ return true;
81
+ } catch (err) {
82
+ const error = err as NodeJS.ErrnoException;
83
+ if (error.code === "EPERM" || error.code === "EBUSY" || error.code === "ENOTEMPTY") {
84
+ console.error("cluaupp: skip remove", target, `(${error.code})`);
85
+ return false;
86
+ }
87
+ throw err;
88
+ }
89
+ }
90
+
91
+ function copyFileIfChanged(src: string, dest: string, mode = "fill"): void {
92
+ if (mode === "fill" && fs.existsSync(dest)) {
93
+ return;
94
+ }
95
+ if (mode === "update" && fs.existsSync(dest)) {
96
+ const from = fs.statSync(src);
97
+ const to = fs.statSync(dest);
98
+ if (from.size === to.size && from.mtimeMs <= to.mtimeMs) {
99
+ return;
100
+ }
101
+ }
102
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
103
+ try {
104
+ fs.copyFileSync(src, dest);
105
+ } catch (err) {
106
+ const error = err as NodeJS.ErrnoException;
107
+ if (error.code === "EPERM" || error.code === "EBUSY") {
108
+ console.error("cluaupp: skip copy", dest, `(${error.code})`);
109
+ return;
110
+ }
111
+ throw err;
112
+ }
113
+ }
114
+
115
+ function syncDir(from: string, to: string, mode = "fill"): void {
116
+ if (!fs.existsSync(from)) {
117
+ return;
118
+ }
119
+ fs.mkdirSync(to, { recursive: true });
120
+ for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
121
+ const src = path.join(from, entry.name);
122
+ const dest = path.join(to, entry.name);
123
+ if (entry.isDirectory()) {
124
+ syncDir(src, dest, mode);
125
+ } else {
126
+ copyFileIfChanged(src, dest, mode);
127
+ }
128
+ }
129
+ }
130
+
131
+ function copyRuntime(dest: string): void {
132
+ const runtime = path.join(__dirname, "..", "..", "runtime");
133
+ if (!fs.existsSync(runtime)) {
134
+ return;
135
+ }
136
+ syncDir(runtime, path.join(dest, "libs"));
137
+ }
138
+
139
+ function copyHeaders(dest: string): void {
140
+ const from = path.join(__dirname, "..", "..", "include", "cluaupp");
141
+ if (!fs.existsSync(from)) {
142
+ return;
143
+ }
144
+ syncDir(from, path.join(dest, "include", "cluaupp"));
145
+ }
146
+
147
+ export function collectCpp(dir: string, files: string[] = []): string[] {
148
+ if (!fs.existsSync(dir)) {
149
+ return files;
150
+ }
151
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
152
+ const full = path.join(dir, entry.name);
153
+ if (entry.isDirectory()) {
154
+ collectCpp(full, files);
155
+ } else if (isSourceFile(entry.name)) {
156
+ files.push(full);
157
+ }
158
+ }
159
+ return files;
160
+ }
161
+
162
+ function collectFiles(dir: string, files: string[] = []): string[] {
163
+ if (!fs.existsSync(dir)) {
164
+ return files;
165
+ }
166
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
167
+ const full = path.join(dir, entry.name);
168
+ if (entry.isDirectory()) {
169
+ collectFiles(full, files);
170
+ } else {
171
+ files.push(full);
172
+ }
173
+ }
174
+ return files;
175
+ }
176
+
177
+ function resolveKey(file: string): string {
178
+ const resolved = path.resolve(file);
179
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
180
+ }
181
+
182
+ function posixRel(from: string, file: string): string {
183
+ return path.relative(from, file).replace(/\\/g, "/");
184
+ }
185
+
186
+ function sourcePrefixes(rel: string): string[] {
187
+ const noExt = rel.replace(/\.(cpp|cc|cxx|c|h|hpp|hh)$/i, "");
188
+ const noTag = noExt.replace(/\.(server|client)$/i, "");
189
+ return [...new Set([noExt, noTag, toLuauPath(rel).replace(/\\/g, "/")])];
190
+ }
191
+
192
+ function matchesPrefix(relOut: string, prefixes: string[]): boolean {
193
+ const n = relOut.replace(/\\/g, "/").toLowerCase();
194
+ return prefixes.some((prefix) => {
195
+ const key = prefix.replace(/\\/g, "/").toLowerCase();
196
+ return n === key || n === `${key}.luau` || n.startsWith(`${key}/`) || n.startsWith(`${key}.`);
197
+ });
198
+ }
199
+
200
+ function removeEmptyOutDirs(outDir: string, projectRootDir: string): void {
201
+ if (!fs.existsSync(outDir)) {
202
+ return;
203
+ }
204
+ const walk = (dir: string) => {
205
+ if (resolveKey(dir) === resolveKey(outDir)) {
206
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
207
+ if (entry.isDirectory()) {
208
+ walk(path.join(dir, entry.name));
209
+ }
210
+ }
211
+ return;
212
+ }
213
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
214
+ if (entry.isDirectory()) {
215
+ walk(path.join(dir, entry.name));
216
+ }
217
+ }
218
+ if (fs.existsSync(dir) && fs.readdirSync(dir).length === 0) {
219
+ if (tryRm(dir, projectRootDir)) {
220
+ console.log("cluaupp: removed", path.relative(projectRootDir, dir));
221
+ }
222
+ }
223
+ };
224
+ walk(outDir);
225
+ }
226
+
227
+ function pruneOut(root: string, config: ProjectConfig, written: Set<string>, failedPrefixes: string[]): void {
228
+ const outDir = path.resolve(root, config.outDir);
229
+ if (!fs.existsSync(outDir) || !isInside(root, outDir)) {
230
+ return;
231
+ }
232
+ for (const file of collectFiles(outDir)) {
233
+ if (written.has(resolveKey(file))) {
234
+ continue;
235
+ }
236
+ if (!isInside(outDir, file)) {
237
+ continue;
238
+ }
239
+ const rel = posixRel(outDir, file);
240
+ if (matchesPrefix(rel, failedPrefixes)) {
241
+ continue;
242
+ }
243
+ if (tryRm(file, outDir)) {
244
+ console.log("cluaupp: removed", path.relative(root, file));
245
+ }
246
+ }
247
+ removeEmptyOutDirs(outDir, root);
248
+ }
249
+
250
+ function createMapper(root: string, config: ProjectConfig, rojoPath?: string): RojoMapper {
251
+ const projectJson = path.resolve(root, rojoPath || "default.project.json");
252
+ const mapper = new RojoMapper(projectJson, path.join(root, config.rootDir), config.outDir);
253
+ mapper.loadSync();
254
+ return mapper;
255
+ }
256
+
257
+ function compileProject(root: string, config: ProjectConfig, mapper: RojoMapper) {
258
+ const srcDir = path.join(root, config.rootDir);
259
+ const files = collectCpp(srcDir);
260
+ const jobs: Array<{ rel: string; files: Array<{ name: string; contents: string }>; stale: string[] }> = [];
261
+ const errors: Array<{ rel: string; prefixes: string[]; message: string }> = [];
262
+
263
+ for (const file of files) {
264
+ const source = fs.readFileSync(file, "utf8");
265
+ const rel = posixRel(srcDir, file);
266
+ try {
267
+ const result = transpileSource(source, rel, {
268
+ ...config,
269
+ filePath: file,
270
+ relativeName: rel,
271
+ outName: toLuauPath(rel),
272
+ architecture: config.architecture === true,
273
+ includeDirs: [path.dirname(file), srcDir, path.join(root, "include")],
274
+ srcDir,
275
+ outDir: config.outDir,
276
+ }, mapper);
277
+ jobs.push({ rel, files: result.files, stale: result.stale || [] });
278
+ } catch (err) {
279
+ errors.push({ rel, prefixes: sourcePrefixes(rel), message: (err as Error).message });
280
+ }
281
+ }
282
+
283
+ return { files, jobs, errors };
284
+ }
285
+
286
+ function writeTextIfChanged(dest: string, contents: string): boolean {
287
+ if (fs.existsSync(dest) && fs.readFileSync(dest, "utf8") === contents) {
288
+ return false;
289
+ }
290
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
291
+ fs.writeFileSync(dest, contents, "utf8");
292
+ return true;
293
+ }
294
+
295
+ function outDest(root: string, config: ProjectConfig, rel: string): string | null {
296
+ const safe = safeRelPath(rel);
297
+ if (!safe) {
298
+ console.error("cluaupp: skip unsafe out path", rel);
299
+ return null;
300
+ }
301
+ const outRoot = path.resolve(root, config.outDir);
302
+ const dest = path.join(outRoot, safe);
303
+ if (!isInside(outRoot, dest) || !isInside(root, dest)) {
304
+ console.error("cluaupp: skip unsafe out path", rel);
305
+ return null;
306
+ }
307
+ return dest;
308
+ }
309
+
310
+ function writeJobs(root: string, config: ProjectConfig, jobs: Array<{ rel: string; files: Array<{ name: string; contents: string }>; stale: string[] }>, format: boolean): Set<string> {
311
+ const written = new Set<string>();
312
+ for (const job of jobs) {
313
+ for (const artifact of job.files) {
314
+ const dest = outDest(root, config, artifact.name);
315
+ if (!dest) {
316
+ continue;
317
+ }
318
+ const changed = writeTextIfChanged(dest, artifact.contents);
319
+ written.add(resolveKey(dest));
320
+ if (changed) {
321
+ console.log("cluaupp:", job.rel, "→", path.relative(root, dest));
322
+ }
323
+ if (format && /\.luau$/i.test(dest)) {
324
+ ProcessOrchestrator.formatWithStyLuaSync(dest);
325
+ }
326
+ }
327
+ for (const stale of job.stale || []) {
328
+ const dest = outDest(root, config, stale);
329
+ if (dest && fs.existsSync(dest) && !written.has(resolveKey(dest))) {
330
+ if (tryRm(dest, path.resolve(root, config.outDir))) {
331
+ console.log("cluaupp: removed", path.relative(root, dest));
332
+ }
333
+ }
334
+ }
335
+ }
336
+ return written;
337
+ }
338
+
339
+ function ensureVendor(root: string): void {
340
+ copyRuntime(root);
341
+ copyHeaders(root);
342
+ }
343
+
344
+ export function build(root: string, options: BuildOptions = {}): BuildResult {
345
+ const exitOnError = options.exitOnError !== false;
346
+ const holdOnError = options.holdOnError === true;
347
+ const format = options.format === true;
348
+ const config = loadConfig(root);
349
+ if (options.syncVendor !== false) {
350
+ ensureVendor(root);
351
+ }
352
+ const srcDir = path.join(root, config.rootDir);
353
+ if (!fs.existsSync(srcDir) || collectCpp(srcDir).length === 0) {
354
+ console.error("no .cpp/.h/.hpp files in", config.rootDir);
355
+ if (!holdOnError) {
356
+ pruneOut(root, config, new Set(), []);
357
+ }
358
+ if (exitOnError) {
359
+ process.exit(1);
360
+ }
361
+ return { failed: 0, written: new Set() };
362
+ }
363
+
364
+ const mapper = createMapper(root, config, options.rojo);
365
+ const compiled = compileProject(root, config, mapper);
366
+ if (compiled.errors.length > 0) {
367
+ for (const err of compiled.errors) {
368
+ console.error(err.message);
369
+ }
370
+ if (holdOnError) {
371
+ console.error(`cluaupp: out not updated (${compiled.errors.length} compile error${compiled.errors.length === 1 ? "" : "s"})`);
372
+ try {
373
+ syncEditorSupport(root, config);
374
+ } catch (err) {
375
+ console.error("cluaupp: intellisense sync failed", (err as Error).message);
376
+ }
377
+ return { failed: compiled.errors.length, written: new Set() };
378
+ }
379
+ }
380
+
381
+ const written = writeJobs(root, config, compiled.jobs, format);
382
+ const failedPrefixes = compiled.errors.flatMap((err) => err.prefixes);
383
+ pruneOut(root, config, written, failedPrefixes);
384
+ try {
385
+ syncEditorSupport(root, config);
386
+ } catch (err) {
387
+ console.error("cluaupp: intellisense sync failed", (err as Error).message);
388
+ }
389
+
390
+ if (options.analyze) {
391
+ for (const file of written) {
392
+ if (/\.luau$/i.test(file)) {
393
+ const report = ProcessOrchestrator.analyzeWithLuauSync(file);
394
+ if (report) {
395
+ console.log("\n📊 Relatório de Análise Estática do Luau:\n", report);
396
+ }
397
+ }
398
+ }
399
+ }
400
+
401
+ if (compiled.errors.length > 0 && exitOnError) {
402
+ process.exit(1);
403
+ }
404
+ return { failed: compiled.errors.length, written };
405
+ }
406
+
407
+ export async function init(dest: string): Promise<void> {
408
+ const target = assertInitDest(dest);
409
+ const template = path.join(__dirname, "..", "..", "templates", "game");
410
+ const include = path.join(__dirname, "..", "..", "include");
411
+ copyDir(template, target);
412
+ copyDir(include, path.join(target, "include"));
413
+ copyRuntime(target);
414
+ syncEditorSupport(target);
415
+ const installed = await installEditorSupport();
416
+ console.log("Cluaupp ready in", target);
417
+ console.log(" rokit install");
418
+ console.log(" cluaupp build");
419
+ console.log(" rojo serve");
420
+ if (
421
+ installed.local.length ||
422
+ (installed.clangd && (installed.clangd.status === "installed" || installed.clangd.status === "already")) ||
423
+ (installed.llvm && (installed.llvm.status === "installed" || installed.llvm.status === "already"))
424
+ ) {
425
+ console.log(" reload Cursor (Ctrl+Shift+P → Developer: Reload Window)");
426
+ }
427
+ }
428
+
429
+ function pidAlive(pid: number): boolean {
430
+ try {
431
+ process.kill(pid, 0);
432
+ return true;
433
+ } catch {
434
+ return false;
435
+ }
436
+ }
437
+
438
+ function acquireWatchLock(root: string): void {
439
+ const file = path.join(root, ".cluaupp-watch.lock");
440
+ if (fs.existsSync(file)) {
441
+ const pid = Number(fs.readFileSync(file, "utf8").trim());
442
+ if (Number.isFinite(pid) && pid !== process.pid && pidAlive(pid)) {
443
+ console.error(`cluaupp: watch already running (pid ${pid}). Stop it first — two watchers make Rojo crash on libs/.`);
444
+ process.exit(1);
445
+ }
446
+ }
447
+ fs.writeFileSync(file, String(process.pid), "utf8");
448
+ const release = () => {
449
+ try {
450
+ if (fs.existsSync(file) && fs.readFileSync(file, "utf8").trim() === String(process.pid)) {
451
+ fs.unlinkSync(file);
452
+ }
453
+ } catch {
454
+ // ignore
455
+ }
456
+ };
457
+ process.on("exit", release);
458
+ process.on("SIGINT", () => {
459
+ release();
460
+ process.exit(0);
461
+ });
462
+ process.on("SIGTERM", () => {
463
+ release();
464
+ process.exit(0);
465
+ });
466
+ }
467
+
468
+ export function watch(root: string, options: BuildOptions = {}): void {
469
+ acquireWatchLock(root);
470
+ build(root, { exitOnError: false, syncVendor: false, holdOnError: true, format: options.format, rojo: options.rojo });
471
+ const config = loadConfig(root);
472
+ const dir = path.join(root, config.rootDir);
473
+ let timer: NodeJS.Timeout | null = null;
474
+ let running = false;
475
+ let queued = false;
476
+
477
+ const run = () => {
478
+ if (running) {
479
+ queued = true;
480
+ return;
481
+ }
482
+ running = true;
483
+ try {
484
+ build(root, { exitOnError: false, syncVendor: false, holdOnError: true, format: options.format, rojo: options.rojo });
485
+ } catch (err) {
486
+ console.error((err as Error).message);
487
+ } finally {
488
+ running = false;
489
+ if (queued) {
490
+ queued = false;
491
+ run();
492
+ }
493
+ }
494
+ };
495
+
496
+ console.log("cluaupp", pkg.version, "watching", dir, "(libs untouched)");
497
+ if (!fs.existsSync(dir)) {
498
+ fs.mkdirSync(dir, { recursive: true });
499
+ }
500
+ fs.watch(dir, { recursive: true }, () => {
501
+ if (timer) {
502
+ clearTimeout(timer);
503
+ }
504
+ timer = setTimeout(run, 400);
505
+ });
506
+ }
@@ -0,0 +1,190 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { parse } from "comment-json";
4
+ import type { RequireBinding, RojoConfig, RojoPathBinding } from "../types.js";
5
+ import { INCLUDE_TO_MODULE, MODULES } from "../libs.js";
6
+ import { isEngineStub } from "../preprocess.js";
7
+
8
+ const SYSTEM_INCLUDES = new Set([
9
+ "iostream",
10
+ "string",
11
+ "string_view",
12
+ "vector",
13
+ "array",
14
+ "map",
15
+ "unordered_map",
16
+ "set",
17
+ "unordered_set",
18
+ "optional",
19
+ "memory",
20
+ "cstdint",
21
+ "cstddef",
22
+ "cmath",
23
+ "algorithm",
24
+ "utility",
25
+ "functional",
26
+ "span",
27
+ "tuple",
28
+ "type_traits",
29
+ ]);
30
+
31
+ function asRecord(value: unknown): Record<string, unknown> | null {
32
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
33
+ return null;
34
+ }
35
+ return value as Record<string, unknown>;
36
+ }
37
+
38
+ function posix(filePath: string): string {
39
+ return filePath.replace(/\\/g, "/");
40
+ }
41
+
42
+ export class RojoMapper {
43
+ private config: RojoConfig | null = null;
44
+ private bindings: RojoPathBinding[] = [];
45
+
46
+ constructor(
47
+ private projectJsonPath: string,
48
+ private srcDir?: string,
49
+ private outDir?: string,
50
+ ) {}
51
+
52
+ public async load(): Promise<void> {
53
+ this.loadSync();
54
+ }
55
+
56
+ public loadSync(): void {
57
+ this.config = null;
58
+ this.bindings = [];
59
+ if (!fs.existsSync(this.projectJsonPath)) {
60
+ return;
61
+ }
62
+ const content = fs.readFileSync(this.projectJsonPath, "utf8");
63
+ this.config = parse(content) as unknown as RojoConfig;
64
+ this.bindings = this.walkTree(this.config.tree, []);
65
+ }
66
+
67
+ public resolveIncludeToRequire(includePath: string): RequireBinding | null {
68
+ const raw = includePath.replace(/[<>'"]/g, "").trim();
69
+ if (!raw) {
70
+ return null;
71
+ }
72
+ const cleanPath = posix(raw).replace(/\.h(pp|h)?$/i, "");
73
+ const baseName = path.posix.basename(cleanPath);
74
+ const key = baseName.toLowerCase();
75
+
76
+ if (isEngineStub(raw) || SYSTEM_INCLUDES.has(key) || SYSTEM_INCLUDES.has(cleanPath.toLowerCase())) {
77
+ return null;
78
+ }
79
+
80
+ const library = this.resolveLibrary(cleanPath, key);
81
+ if (library) {
82
+ return library;
83
+ }
84
+
85
+ const fromTree = this.resolveFromRojoTree(cleanPath, baseName);
86
+ if (fromTree) {
87
+ return fromTree;
88
+ }
89
+
90
+ const segments = cleanPath.split("/").filter(Boolean);
91
+ if (segments[0] === "src") {
92
+ segments.shift();
93
+ }
94
+ const alias = this.safeAlias(baseName);
95
+ if (segments[0] === "shared") {
96
+ return { alias, path: `ReplicatedStorage.Cluaupp.${segments.slice(1).join(".")}` };
97
+ }
98
+ if (segments[0] === "server") {
99
+ return { alias, path: `ServerScriptService.Cluaupp.${segments.slice(1).join(".")}` };
100
+ }
101
+ if (segments[0] === "client") {
102
+ return { alias, path: `StarterPlayer.StarterPlayerScripts.Cluaupp.${segments.slice(1).join(".")}` };
103
+ }
104
+ return { alias, path: `ReplicatedStorage.${segments.join(".")}` };
105
+ }
106
+
107
+ private resolveLibrary(cleanPath: string, key: string): RequireBinding | null {
108
+ const mapped = (INCLUDE_TO_MODULE as Record<string, string | null>)[key]
109
+ ?? (INCLUDE_TO_MODULE as Record<string, string | null>)[path.posix.basename(cleanPath).toLowerCase()];
110
+ if (!mapped) {
111
+ return null;
112
+ }
113
+ const spec = (MODULES as Record<string, { file: string; bind: string }>)[mapped];
114
+ const bind = spec?.bind || mapped;
115
+ const file = spec?.file || mapped;
116
+ const libsRoot = this.findBinding((binding) => binding.robloxPath.endsWith(".CluauppLibs") || binding.robloxPath === "ReplicatedStorage.CluauppLibs");
117
+ const packagesRoot = this.findBinding((binding) => binding.robloxPath.endsWith(".Packages") || binding.robloxPath === "ReplicatedStorage.Packages");
118
+ if (libsRoot) {
119
+ return { alias: bind, path: `${libsRoot.robloxPath}.${file}` };
120
+ }
121
+ if (packagesRoot) {
122
+ return { alias: bind, path: `${packagesRoot.robloxPath}.${file}` };
123
+ }
124
+ return { alias: bind, path: `ReplicatedStorage.CluauppLibs.${file}` };
125
+ }
126
+
127
+ private resolveFromRojoTree(cleanPath: string, baseName: string): RequireBinding | null {
128
+ const outRel = this.guessOutRel(cleanPath);
129
+ if (!outRel || this.bindings.length === 0) {
130
+ return null;
131
+ }
132
+ let best: { binding: RojoPathBinding; rest: string[] } | null = null;
133
+ for (const binding of this.bindings) {
134
+ const prefix = posix(binding.fsPath).replace(/\/$/, "");
135
+ if (outRel === prefix || outRel.startsWith(`${prefix}/`)) {
136
+ const rest = outRel === prefix ? [] : outRel.slice(prefix.length + 1).split("/");
137
+ if (!best || prefix.length > posix(best.binding.fsPath).length) {
138
+ best = { binding, rest };
139
+ }
140
+ }
141
+ }
142
+ if (!best) {
143
+ return null;
144
+ }
145
+ const tail = best.rest.join(".");
146
+ return {
147
+ alias: this.safeAlias(baseName),
148
+ path: tail ? `${best.binding.robloxPath}.${tail}` : best.binding.robloxPath,
149
+ };
150
+ }
151
+
152
+ private guessOutRel(cleanPath: string): string | null {
153
+ const relative = posix(cleanPath).replace(/^src\//, "");
154
+ const outName = relative.replace(/\.(cpp|cc|cxx|c|h|hpp|hh)$/i, "");
155
+ if (this.outDir) {
156
+ return posix(path.join(this.outDir, outName));
157
+ }
158
+ return posix(path.join("out", outName));
159
+ }
160
+
161
+ private walkTree(node: unknown, robloxPath: string[]): RojoPathBinding[] {
162
+ const record = asRecord(node);
163
+ if (!record) {
164
+ return [];
165
+ }
166
+ const found: RojoPathBinding[] = [];
167
+ if (typeof record.$path === "string" && robloxPath.length > 0) {
168
+ found.push({
169
+ robloxPath: robloxPath.join("."),
170
+ fsPath: posix(record.$path),
171
+ });
172
+ }
173
+ for (const [key, child] of Object.entries(record)) {
174
+ if (key.startsWith("$")) {
175
+ continue;
176
+ }
177
+ found.push(...this.walkTree(child, [...robloxPath, key]));
178
+ }
179
+ return found;
180
+ }
181
+
182
+ private findBinding(predicate: (binding: RojoPathBinding) => boolean): RojoPathBinding | undefined {
183
+ return this.bindings.find(predicate);
184
+ }
185
+
186
+ private safeAlias(name: string): string {
187
+ const cleaned = name.replace(/[^A-Za-z0-9_]/g, "_");
188
+ return /^[A-Za-z_]/.test(cleaned) ? cleaned : `_${cleaned}`;
189
+ }
190
+ }