@isentinel/rojo-utils 0.1.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.
- package/CLAUDE.md +40 -0
- package/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/clover.xml +388 -0
- package/coverage/coverage-final.json +7 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +191 -0
- package/coverage/loader.ts.html +220 -0
- package/coverage/mount-collector.ts.html +301 -0
- package/coverage/path-mapper.ts.html +220 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/rojo-resolver.ts.html +1900 -0
- package/coverage/rojo-tree.ts.html +559 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/coverage/tree-mapper.ts.html +247 -0
- package/dist/index.d.mts +175 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +551 -0
- package/dist/index.mjs.map +1 -0
- package/eslint.config.ts +23 -0
- package/out-tsc/index.d.ts +10 -0
- package/out-tsc/index.d.ts.map +1 -0
- package/out-tsc/index.js +6 -0
- package/out-tsc/loader.d.ts +3 -0
- package/out-tsc/loader.d.ts.map +1 -0
- package/out-tsc/loader.js +37 -0
- package/out-tsc/mount-collector.d.ts +10 -0
- package/out-tsc/mount-collector.d.ts.map +1 -0
- package/out-tsc/mount-collector.js +37 -0
- package/out-tsc/path-mapper.d.ts +4 -0
- package/out-tsc/path-mapper.d.ts.map +1 -0
- package/out-tsc/path-mapper.js +34 -0
- package/out-tsc/rojo-resolver.d.ts +110 -0
- package/out-tsc/rojo-resolver.d.ts.map +1 -0
- package/out-tsc/rojo-resolver.js +448 -0
- package/out-tsc/rojo-tree.d.ts +15 -0
- package/out-tsc/rojo-tree.d.ts.map +1 -0
- package/out-tsc/rojo-tree.js +106 -0
- package/out-tsc/tree-mapper.d.ts +4 -0
- package/out-tsc/tree-mapper.d.ts.map +1 -0
- package/out-tsc/tree-mapper.js +34 -0
- package/out-tsc/tsconfig.tsbuildinfo +1 -0
- package/out-tsc/types.d.ts +26 -0
- package/out-tsc/types.d.ts.map +1 -0
- package/out-tsc/types.js +1 -0
- package/out-tsc/vitest/eslint.config.d.ts +3 -0
- package/out-tsc/vitest/eslint.config.d.ts.map +1 -0
- package/out-tsc/vitest/eslint.shared.d.ts +13 -0
- package/out-tsc/vitest/eslint.shared.d.ts.map +1 -0
- package/out-tsc/vitest/libs/rojo-utils/eslint.config.d.ts +3 -0
- package/out-tsc/vitest/libs/rojo-utils/eslint.config.d.ts.map +1 -0
- package/out-tsc/vitest/libs/rojo-utils/src/loader.spec.d.ts +2 -0
- package/out-tsc/vitest/libs/rojo-utils/src/loader.spec.d.ts.map +1 -0
- package/out-tsc/vitest/libs/rojo-utils/src/mount-collector.spec.d.ts +2 -0
- package/out-tsc/vitest/libs/rojo-utils/src/mount-collector.spec.d.ts.map +1 -0
- package/out-tsc/vitest/libs/rojo-utils/src/path-mapper.spec.d.ts +2 -0
- package/out-tsc/vitest/libs/rojo-utils/src/path-mapper.spec.d.ts.map +1 -0
- package/out-tsc/vitest/libs/rojo-utils/src/rojo-resolver.spec.d.ts +2 -0
- package/out-tsc/vitest/libs/rojo-utils/src/rojo-resolver.spec.d.ts.map +1 -0
- package/out-tsc/vitest/libs/rojo-utils/src/rojo-tree.spec.d.ts +2 -0
- package/out-tsc/vitest/libs/rojo-utils/src/rojo-tree.spec.d.ts.map +1 -0
- package/out-tsc/vitest/libs/rojo-utils/src/tree-mapper.spec.d.ts +2 -0
- package/out-tsc/vitest/libs/rojo-utils/src/tree-mapper.spec.d.ts.map +1 -0
- package/out-tsc/vitest/libs/rojo-utils/test/setup/jest-extended.d.ts +2 -0
- package/out-tsc/vitest/libs/rojo-utils/test/setup/jest-extended.d.ts.map +1 -0
- package/out-tsc/vitest/libs/rojo-utils/tsconfig.spec.tsbuildinfo +1 -0
- package/out-tsc/vitest/libs/rojo-utils/tsdown.config.d.ts +3 -0
- package/out-tsc/vitest/libs/rojo-utils/tsdown.config.d.ts.map +1 -0
- package/out-tsc/vitest/libs/rojo-utils/vitest.config.d.ts +3 -0
- package/out-tsc/vitest/libs/rojo-utils/vitest.config.d.ts.map +1 -0
- package/out-tsc/vitest/src/loader.spec.d.ts +2 -0
- package/out-tsc/vitest/src/loader.spec.d.ts.map +1 -0
- package/out-tsc/vitest/src/mount-collector.spec.d.ts +2 -0
- package/out-tsc/vitest/src/mount-collector.spec.d.ts.map +1 -0
- package/out-tsc/vitest/src/path-mapper.spec.d.ts +2 -0
- package/out-tsc/vitest/src/path-mapper.spec.d.ts.map +1 -0
- package/out-tsc/vitest/src/rojo-resolver.spec.d.ts +2 -0
- package/out-tsc/vitest/src/rojo-resolver.spec.d.ts.map +1 -0
- package/out-tsc/vitest/src/rojo-tree.spec.d.ts +2 -0
- package/out-tsc/vitest/src/rojo-tree.spec.d.ts.map +1 -0
- package/out-tsc/vitest/src/tree-mapper.spec.d.ts +2 -0
- package/out-tsc/vitest/src/tree-mapper.spec.d.ts.map +1 -0
- package/out-tsc/vitest/test/setup/jest-extended.d.ts +2 -0
- package/out-tsc/vitest/test/setup/jest-extended.d.ts.map +1 -0
- package/out-tsc/vitest/tsconfig.spec.tsbuildinfo +1 -0
- package/out-tsc/vitest/tsdown.config.d.ts +3 -0
- package/out-tsc/vitest/tsdown.config.d.ts.map +1 -0
- package/out-tsc/vitest/vitest.config.d.ts +3 -0
- package/out-tsc/vitest/vitest.config.d.ts.map +1 -0
- package/package.json +48 -0
- package/project.json +25 -0
- package/src/index.ts +25 -0
- package/src/loader.spec.ts +240 -0
- package/src/loader.ts +45 -0
- package/src/mount-collector.spec.ts +272 -0
- package/src/mount-collector.ts +72 -0
- package/src/path-mapper.spec.ts +201 -0
- package/src/path-mapper.ts +45 -0
- package/src/rojo-resolver.spec.ts +956 -0
- package/src/rojo-resolver.ts +605 -0
- package/src/rojo-tree.spec.ts +622 -0
- package/src/rojo-tree.ts +162 -0
- package/src/tree-mapper.spec.ts +147 -0
- package/src/tree-mapper.ts +54 -0
- package/src/types.ts +29 -0
- package/test/setup/jest-extended.ts +4 -0
- package/test/setup/vitest-jest-extended.d.ts +1 -0
- package/tsconfig.json +13 -0
- package/tsconfig.lib.json +17 -0
- package/tsconfig.lib.tsbuildinfo +1 -0
- package/tsconfig.spec.json +26 -0
- package/tsdown.config.ts +15 -0
- package/vitest.config.ts +20 -0
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
import { type } from "arktype";
|
|
2
|
+
import type { Dirent } from "node:fs";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
const LUA_EXT = ".lua";
|
|
7
|
+
const LUAU_EXT = ".luau";
|
|
8
|
+
const JSON_EXT = ".json";
|
|
9
|
+
const TOML_EXT = ".toml";
|
|
10
|
+
|
|
11
|
+
const ROJO_MODULE_EXTS = new Set([JSON_EXT, LUAU_EXT, TOML_EXT]);
|
|
12
|
+
const ROJO_SCRIPT_EXTS = new Set([LUAU_EXT]);
|
|
13
|
+
|
|
14
|
+
const INIT_NAME = "init";
|
|
15
|
+
|
|
16
|
+
const SERVER_SUB_EXTENSION = ".server";
|
|
17
|
+
const CLIENT_SUB_EXTENSION = ".client";
|
|
18
|
+
const MODULE_SUB_EXTENSION = "";
|
|
19
|
+
|
|
20
|
+
interface RojoTreeProperty {
|
|
21
|
+
Type: string;
|
|
22
|
+
Value: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface RojoTreeMetadata {
|
|
26
|
+
$className?: string;
|
|
27
|
+
$ignoreUnknownInstances?: boolean;
|
|
28
|
+
$path?: string | { optional: string };
|
|
29
|
+
$properties?: Array<RojoTreeProperty>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type RojoTree = RojoTreeMembers & RojoTreeMetadata;
|
|
33
|
+
|
|
34
|
+
interface RojoTreeMembers {
|
|
35
|
+
[name: string]: RojoTree;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface RojoFile {
|
|
39
|
+
name: string;
|
|
40
|
+
servePort?: number;
|
|
41
|
+
tree: RojoTree;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const ROJO_FILE_REGEX = /^.+\.project\.json$/;
|
|
45
|
+
const ROJO_DEFAULT_NAME = "default.project.json";
|
|
46
|
+
const ROJO_OLD_NAME = "roblox-project.json";
|
|
47
|
+
|
|
48
|
+
export const RbxType = {
|
|
49
|
+
LocalScript: 2,
|
|
50
|
+
ModuleScript: 0,
|
|
51
|
+
Script: 1,
|
|
52
|
+
Unknown: 3,
|
|
53
|
+
} as const;
|
|
54
|
+
|
|
55
|
+
export type RbxType = (typeof RbxType)[keyof typeof RbxType];
|
|
56
|
+
|
|
57
|
+
const SUB_EXT_TYPE_MAP = new Map<string, RbxType>([
|
|
58
|
+
[CLIENT_SUB_EXTENSION, RbxType.LocalScript],
|
|
59
|
+
[MODULE_SUB_EXTENSION, RbxType.ModuleScript],
|
|
60
|
+
[SERVER_SUB_EXTENSION, RbxType.Script],
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
/** Represents a roblox tree path. */
|
|
64
|
+
export type RbxPath = ReadonlyArray<string>;
|
|
65
|
+
export type RelativeRbxPath = ReadonlyArray<RbxPathParent | string>;
|
|
66
|
+
|
|
67
|
+
export interface PartitionInfo {
|
|
68
|
+
fsPath: string;
|
|
69
|
+
rbxPath: RbxPath;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const DEFAULT_ISOLATED_CONTAINERS: Array<RbxPath> = [
|
|
73
|
+
["StarterPack"],
|
|
74
|
+
["StarterGui"],
|
|
75
|
+
["StarterPlayer", "StarterPlayerScripts"],
|
|
76
|
+
["StarterPlayer", "StarterCharacterScripts"],
|
|
77
|
+
["StarterPlayer", "StarterCharacter"],
|
|
78
|
+
["PluginDebugService"],
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
const CLIENT_CONTAINERS: Array<RbxPath> = [["StarterPack"], ["StarterGui"], ["StarterPlayer"]];
|
|
82
|
+
const SERVER_CONTAINERS: Array<RbxPath> = [["ServerStorage"], ["ServerScriptService"]];
|
|
83
|
+
|
|
84
|
+
export const FileRelation = {
|
|
85
|
+
InToIn: 3,
|
|
86
|
+
InToOut: 2,
|
|
87
|
+
OutToIn: 1,
|
|
88
|
+
OutToOut: 0,
|
|
89
|
+
} as const;
|
|
90
|
+
|
|
91
|
+
export type FileRelation = (typeof FileRelation)[keyof typeof FileRelation];
|
|
92
|
+
|
|
93
|
+
export const NetworkType = {
|
|
94
|
+
Client: 1,
|
|
95
|
+
Server: 2,
|
|
96
|
+
Unknown: 0,
|
|
97
|
+
} as const;
|
|
98
|
+
|
|
99
|
+
export type NetworkType = (typeof NetworkType)[keyof typeof NetworkType];
|
|
100
|
+
|
|
101
|
+
/** Serializable snapshot of a {@link RojoResolver} for disk caching. */
|
|
102
|
+
export interface RojoResolverState {
|
|
103
|
+
filePathToRbxPathMap: Array<[string, RbxPath]>;
|
|
104
|
+
isGame: boolean;
|
|
105
|
+
isolatedContainers: Array<RbxPath>;
|
|
106
|
+
partitions: Array<PartitionInfo>;
|
|
107
|
+
walkedConfigFiles: Array<string>;
|
|
108
|
+
walkedDirs: Array<string>;
|
|
109
|
+
warnings: Array<string>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function stripRojoExtensions(filePath: string): string {
|
|
113
|
+
let stripped = filePath;
|
|
114
|
+
const extension = path.extname(stripped);
|
|
115
|
+
if (ROJO_MODULE_EXTS.has(extension)) {
|
|
116
|
+
stripped = stripped.slice(0, -extension.length);
|
|
117
|
+
if (ROJO_SCRIPT_EXTS.has(extension)) {
|
|
118
|
+
const subExtension = path.extname(stripped);
|
|
119
|
+
if (subExtension === SERVER_SUB_EXTENSION || subExtension === CLIENT_SUB_EXTENSION) {
|
|
120
|
+
stripped = stripped.slice(0, -subExtension.length);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return stripped;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function arrayStartsWith<T>(a: ReadonlyArray<T>, b: ReadonlyArray<T>): boolean {
|
|
129
|
+
const minLength = Math.min(a.length, b.length);
|
|
130
|
+
for (let index = 0; index < minLength; index++) {
|
|
131
|
+
if (a[index] !== b[index]) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isPathDescendantOf(filePath: string, directoryPath: string): boolean {
|
|
140
|
+
return directoryPath === filePath || !path.relative(directoryPath, filePath).startsWith("..");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let rojoFileSchema: type.Any | undefined;
|
|
144
|
+
|
|
145
|
+
function isValidRojoConfig(value: unknown): value is RojoFile {
|
|
146
|
+
// Built lazily on first use (not at module top level) so consumers can
|
|
147
|
+
// auto-mock this module without a hoisting TDZ on the arktype import, then
|
|
148
|
+
// memoized so repeated tree-walk validations don't re-allocate the schema. We
|
|
149
|
+
// validate only the fields the resolver reads; parseTree handles arbitrary
|
|
150
|
+
// tree shapes defensively.
|
|
151
|
+
rojoFileSchema ??= type({
|
|
152
|
+
"name": "string",
|
|
153
|
+
"servePort?": "number",
|
|
154
|
+
"tree": "object",
|
|
155
|
+
});
|
|
156
|
+
return !(rojoFileSchema(value) instanceof type.errors);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function convertToLuau(filePath: string): string {
|
|
160
|
+
const extension = path.extname(filePath);
|
|
161
|
+
if (extension === LUA_EXT) {
|
|
162
|
+
return filePath.slice(0, -extension.length) + LUAU_EXT;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return filePath;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export const RbxPathParent: unique symbol = Symbol("Parent");
|
|
169
|
+
export type RbxPathParent = typeof RbxPathParent;
|
|
170
|
+
|
|
171
|
+
export class RojoResolver {
|
|
172
|
+
private readonly rbxPath = new Array<string>();
|
|
173
|
+
private readonly realpathCache = new Map<string, string>();
|
|
174
|
+
private readonly walkedConfigFilesInternal = new Set<string>();
|
|
175
|
+
private readonly walkedDirectoriesInternal = new Set<string>();
|
|
176
|
+
|
|
177
|
+
private filePathToRbxPathMap = new Map<string, RbxPath>();
|
|
178
|
+
private isolatedContainers = [...DEFAULT_ISOLATED_CONTAINERS];
|
|
179
|
+
private partitions = new Array<PartitionInfo>();
|
|
180
|
+
private warnings = new Array<string>();
|
|
181
|
+
|
|
182
|
+
public isGame = false;
|
|
183
|
+
|
|
184
|
+
public static findRojoConfigFilePath(projectPath: string): {
|
|
185
|
+
path: string | undefined;
|
|
186
|
+
warnings: Array<string>;
|
|
187
|
+
} {
|
|
188
|
+
const warnings = new Array<string>();
|
|
189
|
+
|
|
190
|
+
const defaultPath = path.join(projectPath, ROJO_DEFAULT_NAME);
|
|
191
|
+
if (fs.existsSync(defaultPath)) {
|
|
192
|
+
return { path: defaultPath, warnings };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const candidates = new Array<string | undefined>();
|
|
196
|
+
for (const fileName of fs.readdirSync(projectPath)) {
|
|
197
|
+
if (
|
|
198
|
+
fileName !== ROJO_DEFAULT_NAME &&
|
|
199
|
+
(fileName === ROJO_OLD_NAME || ROJO_FILE_REGEX.test(fileName))
|
|
200
|
+
) {
|
|
201
|
+
candidates.push(path.join(projectPath, fileName));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (candidates.length > 1) {
|
|
206
|
+
warnings.push(`Multiple *.project.json files found, using ${candidates[0]}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { path: candidates[0], warnings };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
public static fromPath(rojoConfigFilePath: string): RojoResolver {
|
|
213
|
+
const resolver = new RojoResolver();
|
|
214
|
+
resolver.parseConfig(path.resolve(rojoConfigFilePath), true);
|
|
215
|
+
return resolver;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Restore a resolver from a {@link RojoResolverState} snapshot.
|
|
220
|
+
* @param state - The serialized resolver state to restore from.
|
|
221
|
+
* @returns A resolver equivalent to the one the state was captured from.
|
|
222
|
+
*/
|
|
223
|
+
public static fromState(state: RojoResolverState): RojoResolver {
|
|
224
|
+
const resolver = new RojoResolver();
|
|
225
|
+
|
|
226
|
+
resolver.partitions = state.partitions.map((partition) => {
|
|
227
|
+
return { fsPath: partition.fsPath, rbxPath: partition.rbxPath.slice() };
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const filePathToRbxPathMap = new Map<string, RbxPath>();
|
|
231
|
+
for (const [filePath, rbxPath] of state.filePathToRbxPathMap) {
|
|
232
|
+
filePathToRbxPathMap.set(filePath, rbxPath.slice());
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
resolver.filePathToRbxPathMap = filePathToRbxPathMap;
|
|
236
|
+
resolver.isolatedContainers = state.isolatedContainers.map((container) => {
|
|
237
|
+
return container.slice();
|
|
238
|
+
});
|
|
239
|
+
resolver.isGame = state.isGame;
|
|
240
|
+
resolver.warnings = state.warnings.slice();
|
|
241
|
+
|
|
242
|
+
for (const directory of state.walkedDirs) {
|
|
243
|
+
resolver.walkedDirectoriesInternal.add(directory);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
for (const configFile of state.walkedConfigFiles) {
|
|
247
|
+
resolver.walkedConfigFilesInternal.add(configFile);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return resolver;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
public static fromTree(basePath: string, tree: RojoTree): RojoResolver {
|
|
254
|
+
const resolver = new RojoResolver();
|
|
255
|
+
resolver.parseTree(basePath, "", tree, true);
|
|
256
|
+
return resolver;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
public getFileRelation(fileRbxPath: RbxPath, moduleRbxPath: RbxPath): FileRelation {
|
|
260
|
+
const fileContainer = this.getContainer(this.isolatedContainers, fileRbxPath);
|
|
261
|
+
const moduleContainer = this.getContainer(this.isolatedContainers, moduleRbxPath);
|
|
262
|
+
if (fileContainer && moduleContainer) {
|
|
263
|
+
if (fileContainer === moduleContainer) {
|
|
264
|
+
return FileRelation.InToIn;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return FileRelation.OutToIn;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (fileContainer && !moduleContainer) {
|
|
271
|
+
return FileRelation.InToOut;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (!fileContainer && moduleContainer) {
|
|
275
|
+
return FileRelation.OutToIn;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// !fileContainer && !moduleContainer
|
|
279
|
+
return FileRelation.OutToOut;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
public getNetworkType(rbxPath: RbxPath): NetworkType {
|
|
283
|
+
if (this.getContainer(SERVER_CONTAINERS, rbxPath)) {
|
|
284
|
+
return NetworkType.Server;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (this.getContainer(CLIENT_CONTAINERS, rbxPath)) {
|
|
288
|
+
return NetworkType.Client;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return NetworkType.Unknown;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
public getPartitions(): ReadonlyArray<PartitionInfo> {
|
|
295
|
+
return this.partitions;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
public getRbxPathFromFilePath(filePath: string): RbxPath | undefined {
|
|
299
|
+
const resolved = convertToLuau(path.resolve(filePath));
|
|
300
|
+
|
|
301
|
+
const rbxPath = this.filePathToRbxPathMap.get(resolved);
|
|
302
|
+
if (rbxPath) {
|
|
303
|
+
return rbxPath;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const extension = path.extname(resolved);
|
|
307
|
+
for (const partition of this.partitions) {
|
|
308
|
+
if (isPathDescendantOf(resolved, partition.fsPath)) {
|
|
309
|
+
const stripped = stripRojoExtensions(resolved);
|
|
310
|
+
const relativePath = path.relative(partition.fsPath, stripped);
|
|
311
|
+
const relativeParts = relativePath === "" ? [] : relativePath.split(path.sep);
|
|
312
|
+
if (ROJO_SCRIPT_EXTS.has(extension) && relativeParts.at(-1) === INIT_NAME) {
|
|
313
|
+
relativeParts.pop();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return [...partition.rbxPath, ...relativeParts];
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
public getRbxTypeFromFilePath(filePath: string): RbxType {
|
|
324
|
+
const resolved = convertToLuau(filePath);
|
|
325
|
+
const extension = path.extname(resolved);
|
|
326
|
+
const subExtension = path.extname(path.basename(resolved, extension));
|
|
327
|
+
if (ROJO_SCRIPT_EXTS.has(extension)) {
|
|
328
|
+
return SUB_EXT_TYPE_MAP.get(subExtension) ?? RbxType.Unknown;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// non-script exts cannot use .server, .client, etc.
|
|
332
|
+
return RbxType.ModuleScript;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Serialize this resolver to a {@link RojoResolverState} snapshot.
|
|
337
|
+
* @returns A serializable snapshot of this resolver's state.
|
|
338
|
+
*/
|
|
339
|
+
public getState(): RojoResolverState {
|
|
340
|
+
const filePathToRbxPathMap = new Array<[string, RbxPath]>();
|
|
341
|
+
for (const [filePath, rbxPath] of this.filePathToRbxPathMap) {
|
|
342
|
+
filePathToRbxPathMap.push([filePath, rbxPath.slice()]);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
filePathToRbxPathMap,
|
|
347
|
+
isGame: this.isGame,
|
|
348
|
+
isolatedContainers: this.isolatedContainers.map((container) => container.slice()),
|
|
349
|
+
partitions: this.partitions.map((partition) => {
|
|
350
|
+
return { fsPath: partition.fsPath, rbxPath: partition.rbxPath.slice() };
|
|
351
|
+
}),
|
|
352
|
+
walkedConfigFiles: [...this.walkedConfigFilesInternal],
|
|
353
|
+
walkedDirs: [...this.walkedDirectoriesInternal],
|
|
354
|
+
warnings: this.warnings.slice(),
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
public getWarnings(): ReadonlyArray<string> {
|
|
359
|
+
return this.warnings;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
public isIsolated(rbxPath: RbxPath): boolean {
|
|
363
|
+
return this.getContainer(this.isolatedContainers, rbxPath) !== undefined;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
public static relative(rbxFrom: RbxPath, rbxTo: RbxPath): RelativeRbxPath {
|
|
367
|
+
const maxLength = Math.max(rbxFrom.length, rbxTo.length);
|
|
368
|
+
let diffIndex = maxLength;
|
|
369
|
+
for (let index = 0; index < maxLength; index++) {
|
|
370
|
+
if (rbxFrom[index] !== rbxTo[index]) {
|
|
371
|
+
diffIndex = index;
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const result = new Array<RbxPathParent | string>();
|
|
377
|
+
if (diffIndex < rbxFrom.length) {
|
|
378
|
+
for (let index = 0; index < rbxFrom.length - diffIndex; index++) {
|
|
379
|
+
result.push(RbxPathParent);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
for (let index = diffIndex; index < rbxTo.length; index++) {
|
|
384
|
+
// eslint-disable-next-line ts/no-non-null-assertion -- Loop index
|
|
385
|
+
result.push(rbxTo[index]!);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return result;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Create a synthetic RojoResolver for ProjectType.Package. Forces all imports
|
|
393
|
+
* to be relative.
|
|
394
|
+
* @param basePath - The base filesystem path the package resolves against.
|
|
395
|
+
* @returns A resolver that maps every file in the package relatively.
|
|
396
|
+
*/
|
|
397
|
+
public static synthetic(basePath: string): RojoResolver {
|
|
398
|
+
const resolver = new RojoResolver();
|
|
399
|
+
resolver.parseTree(basePath, "", { $path: basePath } as RojoTree, true);
|
|
400
|
+
return resolver;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
public get walkedConfigFiles(): ReadonlySet<string> {
|
|
404
|
+
return this.walkedConfigFilesInternal;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
public get walkedDirectories(): ReadonlySet<string> {
|
|
408
|
+
return this.walkedDirectoriesInternal;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private cachedRealpath(targetPath: string): string {
|
|
412
|
+
let resolved = this.realpathCache.get(targetPath);
|
|
413
|
+
if (resolved === undefined) {
|
|
414
|
+
resolved = fs.realpathSync(targetPath);
|
|
415
|
+
this.realpathCache.set(targetPath, resolved);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return resolved;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
private getContainer(from: Array<RbxPath>, rbxPath?: RbxPath): RbxPath | undefined {
|
|
422
|
+
if (rbxPath && this.isGame) {
|
|
423
|
+
for (const container of from) {
|
|
424
|
+
if (arrayStartsWith(rbxPath, container)) {
|
|
425
|
+
return container;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return undefined;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private parseConfig(rojoConfigFilePath: string, doNotPush = false): void {
|
|
434
|
+
if (!fs.existsSync(rojoConfigFilePath)) {
|
|
435
|
+
this.warn(`RojoResolver: Path does not exist "${rojoConfigFilePath}"`);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const realPath = this.cachedRealpath(rojoConfigFilePath);
|
|
440
|
+
this.walkedConfigFilesInternal.add(realPath);
|
|
441
|
+
|
|
442
|
+
let configJson: unknown;
|
|
443
|
+
try {
|
|
444
|
+
configJson = JSON.parse(fs.readFileSync(realPath, "utf8"));
|
|
445
|
+
} catch {
|
|
446
|
+
// Malformed JSON: leave configJson undefined and fall through so it
|
|
447
|
+
// is reported as an invalid configuration rather than crashing the
|
|
448
|
+
// caller.
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (isValidRojoConfig(configJson)) {
|
|
452
|
+
this.parseTree(
|
|
453
|
+
path.dirname(rojoConfigFilePath),
|
|
454
|
+
configJson.name,
|
|
455
|
+
configJson.tree,
|
|
456
|
+
doNotPush,
|
|
457
|
+
);
|
|
458
|
+
} else {
|
|
459
|
+
this.warn("RojoResolver: Invalid configuration!");
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
private parsePath(itemPath: string): void {
|
|
464
|
+
const luauPath = convertToLuau(itemPath);
|
|
465
|
+
|
|
466
|
+
// A $path pointing straight at a *.project.json file embeds that nested
|
|
467
|
+
// project at the current rbxPath (Rojo composes projects this way), the
|
|
468
|
+
// same as a directory whose default.project.json is discovered below.
|
|
469
|
+
// Without this, the .json extension routes the file into
|
|
470
|
+
// filePathToRbxPathMap as an opaque leaf and the nested tree's mounts
|
|
471
|
+
// (its partitions, its @rbxts mounts) are never read.
|
|
472
|
+
if (ROJO_FILE_REGEX.test(path.basename(luauPath))) {
|
|
473
|
+
this.parseConfig(luauPath, true);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const realPath = fs.existsSync(luauPath) ? this.cachedRealpath(luauPath) : luauPath;
|
|
478
|
+
const extension = path.extname(luauPath);
|
|
479
|
+
if (ROJO_MODULE_EXTS.has(extension)) {
|
|
480
|
+
this.filePathToRbxPathMap.set(luauPath, [...this.rbxPath]);
|
|
481
|
+
} else {
|
|
482
|
+
const isDirectory = fs.existsSync(realPath) && fs.statSync(realPath).isDirectory();
|
|
483
|
+
if (isDirectory) {
|
|
484
|
+
this.walkedDirectoriesInternal.add(realPath);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (isDirectory && fs.readdirSync(realPath).includes(ROJO_DEFAULT_NAME)) {
|
|
488
|
+
this.parseConfig(path.join(luauPath, ROJO_DEFAULT_NAME), true);
|
|
489
|
+
} else {
|
|
490
|
+
this.partitions.unshift({
|
|
491
|
+
fsPath: luauPath,
|
|
492
|
+
rbxPath: [...this.rbxPath],
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
if (isDirectory) {
|
|
496
|
+
this.searchDirectory(luauPath);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private parseTree(basePath: string, name: string, tree: RojoTree, doNotPush = false): void {
|
|
503
|
+
if (!doNotPush) {
|
|
504
|
+
this.rbxPath.push(name);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (tree.$path !== undefined) {
|
|
508
|
+
this.parsePath(
|
|
509
|
+
path.resolve(
|
|
510
|
+
basePath,
|
|
511
|
+
typeof tree.$path === "string" ? tree.$path : tree.$path.optional,
|
|
512
|
+
),
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (tree.$className === "DataModel") {
|
|
517
|
+
this.isGame = true;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const childNames = Object.keys(tree).filter((value) => !value.startsWith("$"));
|
|
521
|
+
for (const childName of childNames) {
|
|
522
|
+
// eslint-disable-next-line ts/no-non-null-assertion -- Object.keys ensures this is defined
|
|
523
|
+
this.parseTree(basePath, childName, tree[childName]!);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (!doNotPush) {
|
|
527
|
+
this.rbxPath.pop();
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
private searchChildren(directory: string, directoryEntries: Array<Dirent>): void {
|
|
532
|
+
// Two-phase: parse every sibling *.project.json before recursing into
|
|
533
|
+
// sibling directories. parsePath / parseConfig insert partitions via
|
|
534
|
+
// unshift, and getRbxPathFromFilePath returns the first match, so
|
|
535
|
+
// interleaving in raw entry order would shuffle partition precedence
|
|
536
|
+
// for overlapping mounts.
|
|
537
|
+
const projectFiles = new Array<string>();
|
|
538
|
+
const subDirectories = new Array<{ name: string; path: string }>();
|
|
539
|
+
|
|
540
|
+
for (const entry of directoryEntries) {
|
|
541
|
+
const childPath = path.join(directory, entry.name);
|
|
542
|
+
let isFile = entry.isFile();
|
|
543
|
+
let isDirectory = entry.isDirectory();
|
|
544
|
+
// Symlinks (both flags false), unknown-type entries on NFS/SMB/FUSE
|
|
545
|
+
// mounts and some Windows network drives, and NTFS junctions on
|
|
546
|
+
// Node versions that don't classify them: resolve and stat the
|
|
547
|
+
// target so we still discover nested *.project.json files and
|
|
548
|
+
// walkable directories. A broken symlink throws ENOENT here —
|
|
549
|
+
// warn and skip rather than crash the whole walk.
|
|
550
|
+
if (!isFile && !isDirectory) {
|
|
551
|
+
try {
|
|
552
|
+
const stat = fs.statSync(this.cachedRealpath(childPath));
|
|
553
|
+
isFile = stat.isFile();
|
|
554
|
+
isDirectory = stat.isDirectory();
|
|
555
|
+
} catch (err) {
|
|
556
|
+
this.warn(
|
|
557
|
+
`RojoResolver: Failed to resolve "${childPath}" (${(err as Error).message})`,
|
|
558
|
+
);
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (isFile && ROJO_FILE_REGEX.test(entry.name)) {
|
|
564
|
+
projectFiles.push(childPath);
|
|
565
|
+
} else if (isDirectory) {
|
|
566
|
+
subDirectories.push({ name: entry.name, path: childPath });
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
for (const childPath of projectFiles) {
|
|
571
|
+
this.parseConfig(childPath);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
for (const { name, path: childPath } of subDirectories) {
|
|
575
|
+
this.searchDirectory(childPath, name);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
private searchDirectory(directory: string, item?: string): void {
|
|
580
|
+
const realPath = this.cachedRealpath(directory);
|
|
581
|
+
this.walkedDirectoriesInternal.add(realPath);
|
|
582
|
+
// readdirSync transparently follows a symlink on the argument; the
|
|
583
|
+
// result is equivalent to readdirSync(realpathSync(directory)).
|
|
584
|
+
const directoryEntries = fs.readdirSync(directory, { withFileTypes: true });
|
|
585
|
+
|
|
586
|
+
if (directoryEntries.some((entry) => entry.name === ROJO_DEFAULT_NAME)) {
|
|
587
|
+
this.parseConfig(path.join(directory, ROJO_DEFAULT_NAME));
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (item !== undefined) {
|
|
592
|
+
this.rbxPath.push(item);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
this.searchChildren(directory, directoryEntries);
|
|
596
|
+
|
|
597
|
+
if (item !== undefined) {
|
|
598
|
+
this.rbxPath.pop();
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
private warn(str: string): void {
|
|
603
|
+
this.warnings.push(str);
|
|
604
|
+
}
|
|
605
|
+
}
|