skir 1.1.2 → 1.1.4

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/src/module_set.ts CHANGED
@@ -35,7 +35,6 @@ import {
35
35
  type UnresolvedType,
36
36
  type Value,
37
37
  } from "skir-internal";
38
- import { FileReader } from "./io.js";
39
38
  import {
40
39
  isStringLiteral,
41
40
  literalValueToDenseJson,
@@ -43,37 +42,52 @@ import {
43
42
  valueHasPrimitiveType,
44
43
  } from "./literals.js";
45
44
  import { parseModule } from "./parser.js";
46
- import { tokenizeModule } from "./tokenizer.js";
45
+ import { ModuleTokens, tokenizeModule } from "./tokenizer.js";
47
46
 
47
+ /**
48
+ * Result of compiling a set of modules. Immutable.
49
+ *
50
+ * Support incremental compilation by accepting an optional cache of the previous
51
+ * module set. This cache can be used to avoid re-parsing and re-resolving
52
+ * modules that haven't changed since the last compilation.
53
+ */
48
54
  export class ModuleSet {
49
- static create(fileReader: FileReader, rootPath: string): ModuleSet {
50
- return new ModuleSet(new DefaultModuleParser(fileReader, rootPath));
55
+ static compile(
56
+ modulePathToContent: ReadonlyMap<string, string>,
57
+ cache?: ModuleSet,
58
+ parseMode: "strict" | "lenient" = "strict",
59
+ ): ModuleSet {
60
+ return new ModuleSet(modulePathToContent, cache, parseMode);
51
61
  }
52
62
 
53
- static fromMap(map: ReadonlyMap<string, string>): ModuleSet {
54
- const result = new ModuleSet(new MapBasedModuleParser(map));
55
- for (const modulePath of map.keys()) {
56
- result.parseAndResolve(modulePath);
63
+ constructor(
64
+ private readonly modulePathToContent: ReadonlyMap<string, string>,
65
+ cache: ModuleSet | undefined,
66
+ private readonly parseMode: "strict" | "lenient",
67
+ ) {
68
+ this.cache = cache
69
+ ? new Cache(modulePathToContent, cache.moduleBundles)
70
+ : undefined;
71
+ for (const modulePath of modulePathToContent.keys()) {
72
+ this.parseAndResolve(modulePath, new Set<string>());
57
73
  }
58
- return result;
74
+ this.finalizationResult = this.finalize();
75
+ // So it can be garbage collected.
76
+ this.cache = undefined;
59
77
  }
60
78
 
61
- constructor(private readonly moduleParser: ModuleParser) {}
62
-
63
- parseAndResolve(
79
+ private parseAndResolve(
64
80
  modulePath: string,
65
- inProgressSet?: Set<string>,
66
- ): Result<Module | null> {
67
- const inMap = this.mutableModules.get(modulePath);
81
+ inProgressSet: Set<string>,
82
+ ): ModuleBundle | null {
83
+ const inMap = this.moduleBundles.get(modulePath);
68
84
  if (inMap !== undefined) {
69
85
  return inMap;
70
86
  }
71
- const result = this.doParseAndResolve(
72
- modulePath,
73
- inProgressSet || new Set<string>(),
74
- );
75
- this.mutableModules.set(modulePath, result);
76
- this.mutableErrors.push(...result.errors);
87
+ const result = this.doParseAndResolve(modulePath, inProgressSet);
88
+ if (result) {
89
+ this.moduleBundles.set(modulePath, result);
90
+ }
77
91
  return result;
78
92
  }
79
93
 
@@ -81,19 +95,46 @@ export class ModuleSet {
81
95
  private doParseAndResolve(
82
96
  modulePath: string,
83
97
  inProgressSet: Set<string>,
84
- ): Result<Module | null> {
85
- const errors: SkirError[] = [];
98
+ ): ModuleBundle | null {
99
+ const moduleContent = this.modulePathToContent.get(modulePath);
100
+ if (moduleContent === undefined) {
101
+ return null;
102
+ }
86
103
 
87
- let module: MutableModule;
104
+ let moduleTokens: Result<ModuleTokens>;
88
105
  {
89
- const parseResult = this.moduleParser.parseModule(modulePath);
90
- if (parseResult.result === null) {
91
- return parseResult;
106
+ const moduleCacheResult = this.cache?.getModuleCacheResult(
107
+ modulePath,
108
+ ) ?? { kind: "no-cache" };
109
+ switch (moduleCacheResult.kind) {
110
+ case "no-cache": {
111
+ moduleTokens = tokenizeModule(moduleContent, modulePath);
112
+ break;
113
+ }
114
+ case "module-tokens": {
115
+ moduleTokens = moduleCacheResult.tokens;
116
+ break;
117
+ }
118
+ case "module-bundle": {
119
+ this.registry.mergeFrom(moduleCacheResult.bundle.registry);
120
+ return moduleCacheResult.bundle;
121
+ }
92
122
  }
123
+ }
124
+
125
+ let module: MutableModule;
126
+ const errors: SkirError[] = [];
127
+ {
128
+ const parseResult = parseModule(moduleTokens.result, this.parseMode);
93
129
  errors.push(...parseResult.errors);
94
130
  module = parseResult.result;
95
131
  }
96
132
 
133
+ const moduleBundle = new ModuleBundle(moduleTokens, {
134
+ result: module,
135
+ errors: errors,
136
+ });
137
+
97
138
  // Process all imports.
98
139
  const pathToImports = new Map<string, Array<Import | ImportAlias>>();
99
140
  for (const declaration of module.declarations) {
@@ -133,13 +174,21 @@ export class ModuleSet {
133
174
  const otherModule = this.parseAndResolve(otherModulePath, inProgressSet);
134
175
  inProgressSet.delete(modulePath);
135
176
 
136
- if (otherModule.result === null) {
177
+ if (otherModule === null) {
137
178
  errors.push({
138
179
  token: declaration.modulePath,
139
180
  message: "Module not found",
181
+ expectedNames: suggestModulePaths(
182
+ unquoteAndUnescape(declaration.modulePath.text),
183
+ modulePath,
184
+ this.modulePathToContent,
185
+ ),
140
186
  });
141
- } else if (otherModule.errors.length !== 0) {
142
- const hasCircularDependency = otherModule.errors.some(
187
+ } else if (
188
+ otherModule.tokens.errors.length !== 0 ||
189
+ otherModule.module.errors.length !== 0
190
+ ) {
191
+ const hasCircularDependency = otherModule.module.errors.some(
143
192
  (e) => e.message === circularDependencyMessage,
144
193
  );
145
194
  if (hasCircularDependency) {
@@ -159,13 +208,13 @@ export class ModuleSet {
159
208
  // module and are not imported symbols themselves.
160
209
  for (const importedName of declaration.importedNames) {
161
210
  const importedDeclaration =
162
- otherModule.result.nameToDeclaration[importedName.text];
211
+ otherModule.module.result.nameToDeclaration[importedName.text];
163
212
  if (importedDeclaration === undefined) {
164
213
  errors.push({
165
214
  token: importedName,
166
215
  message: "Not found",
167
216
  expectedNames: declarationsToExpectedNames(
168
- otherModule.result.nameToDeclaration,
217
+ otherModule.module.result.nameToDeclaration,
169
218
  (d) => d.kind === "record",
170
219
  ),
171
220
  });
@@ -185,7 +234,7 @@ export class ModuleSet {
185
234
  }
186
235
 
187
236
  const pathToImportedNames = module.pathToImportedNames;
188
- for (const [path, imports] of pathToImports.entries()) {
237
+ for (const [path, imports] of pathToImports) {
189
238
  const importsNoAlias = imports.filter(
190
239
  (i): i is Import => i.kind === "import",
191
240
  );
@@ -232,38 +281,21 @@ export class ModuleSet {
232
281
  }
233
282
  }
234
283
 
235
- const result: Result<Module> = {
236
- result: module,
237
- errors: errors,
238
- };
239
-
240
284
  if (errors.length) {
241
- return result;
285
+ return moduleBundle;
242
286
  }
243
287
 
244
- this.mutableResolvedModules.push(module);
245
-
246
288
  // We can't merge these 3 loops into a single one, each operation must run
247
289
  // after the last operation ran on the whole map.
248
290
 
249
291
  // Loop 1: merge the module records map into the cross-module record map.
250
292
  for (const record of module.records) {
251
293
  const { key } = record.record;
252
- this.mutableRecordMap.set(key, record);
294
+ this.registry.recordMap.set(key, record);
295
+ moduleBundle.registry.recordMap.set(key, record);
253
296
  const { recordNumber } = record.record;
254
297
  if (recordNumber != null && !modulePath.startsWith("@")) {
255
- const existing = this.numberToRecord.get(recordNumber);
256
- if (existing === undefined) {
257
- this.numberToRecord.set(recordNumber, key);
258
- } else {
259
- const otherRecord = this.recordMap.get(existing)!;
260
- const otherRecordName = otherRecord.record.name.text;
261
- const otherModulePath = otherRecord.modulePath;
262
- errors.push({
263
- token: record.record.name,
264
- message: `Same number as ${otherRecordName} in ${otherModulePath}`,
265
- });
266
- }
298
+ moduleBundle.registry.pushNumberRecord(recordNumber, key);
267
299
  }
268
300
  }
269
301
 
@@ -272,7 +304,7 @@ export class ModuleSet {
272
304
  const usedImports = new Set<string>();
273
305
  const typeResolver = new TypeResolver(
274
306
  module,
275
- this.mutableModules,
307
+ this.moduleBundles,
276
308
  usedImports,
277
309
  errors,
278
310
  );
@@ -326,20 +358,8 @@ export class ModuleSet {
326
358
  this.validateArrayKeys(responseType, errors);
327
359
  }
328
360
  }
329
- if (!modulePath.startsWith("@")) {
330
- const { number } = method;
331
- const existing = this.numberToMethod.get(number);
332
- if (existing === undefined) {
333
- this.numberToMethod.set(number, method);
334
- } else {
335
- const otherMethodName = existing.name.text;
336
- const otherModulePath = existing.name.line.modulePath;
337
- errors.push({
338
- token: method.name,
339
- message: `Same number as ${otherMethodName} in ${otherModulePath}`,
340
- });
341
- }
342
- }
361
+ const { number } = method;
362
+ moduleBundle.registry.pushNumberMethod(number, method);
343
363
  // Resolve the references in the doc comments of the method.
344
364
  this.resolveDocReferences(method, module, errors);
345
365
  }
@@ -359,7 +379,9 @@ export class ModuleSet {
359
379
 
360
380
  ensureAllImportsAreUsed(module, usedImports, errors);
361
381
 
362
- return result;
382
+ this.registry.mergeFrom(moduleBundle.registry);
383
+
384
+ return moduleBundle;
363
385
  }
364
386
 
365
387
  private storeResolvedFieldTypes(
@@ -915,8 +937,8 @@ export class ModuleSet {
915
937
  if (!resolvedModulePath) {
916
938
  return false;
917
939
  }
918
- const importedModule = this.mutableModules.get(resolvedModulePath!);
919
- if (!importedModule?.result) {
940
+ const importedModule = this.moduleBundles.get(resolvedModulePath!);
941
+ if (!importedModule) {
920
942
  return false;
921
943
  }
922
944
  let newNameChain: readonly MutableDocReferenceName[];
@@ -929,7 +951,7 @@ export class ModuleSet {
929
951
  return tryResolveReference(
930
952
  ref,
931
953
  newNameChain,
932
- importedModule.result,
954
+ importedModule.module.result,
933
955
  );
934
956
  }
935
957
  case "constant":
@@ -944,7 +966,7 @@ export class ModuleSet {
944
966
  }
945
967
  };
946
968
 
947
- const { recordMap } = this;
969
+ const { recordMap } = this.registry;
948
970
  // Build list of naming scopes to search, in order of priority.
949
971
  const scopes: Array<Record | Module> = [];
950
972
  const pushRecordAncestorsToScopes = (record: Record): void => {
@@ -1012,56 +1034,107 @@ export class ModuleSet {
1012
1034
  }
1013
1035
  }
1014
1036
 
1015
- mergeFrom(other: ModuleSet): void {
1016
- for (const [key, value] of other.mutableModules.entries()) {
1017
- this.mutableModules.set(key, value);
1037
+ finalize(): FinalizationResult {
1038
+ type MutableModuleResult = {
1039
+ result: Module;
1040
+ errors: SkirError[];
1041
+ };
1042
+ const modules = new Map<string, MutableModuleResult>();
1043
+
1044
+ for (const [modulePath, moduleBundle] of this.moduleBundles) {
1045
+ const { module, tokens } = moduleBundle;
1046
+ const moduleErrors = tokens.errors.length ? tokens.errors : module.errors;
1047
+ modules.set(modulePath, {
1048
+ result: module.result,
1049
+ errors: [...moduleErrors],
1050
+ });
1018
1051
  }
1019
- for (const [key, value] of other.recordMap.entries()) {
1020
- this.mutableRecordMap.set(key, value);
1052
+
1053
+ // Look for duplicate method numbers.
1054
+ for (const methods of this.registry.numberToMethods.values()) {
1055
+ if (methods.length <= 1) {
1056
+ continue;
1057
+ }
1058
+ const pushError = (method: Method, other: Method): void => {
1059
+ const modulePath = method.name.line.modulePath;
1060
+ const moduleResult = modules.get(modulePath)!;
1061
+ const otherMethodName = other.name.text;
1062
+ const otherModulePath = other.name.line.modulePath;
1063
+ moduleResult.errors.push({
1064
+ token: method.name,
1065
+ message: `Same number as ${otherMethodName} in ${otherModulePath}`,
1066
+ });
1067
+ };
1068
+ pushError(methods[0]!, methods[1]!);
1069
+ for (let i = 1; i < methods.length; ++i) {
1070
+ pushError(methods[i]!, methods[0]!);
1071
+ }
1021
1072
  }
1022
- this.mutableResolvedModules.push(...other.resolvedModules);
1023
- for (const [key, value] of other.numberToRecord.entries()) {
1024
- this.numberToRecord.set(key, value);
1073
+
1074
+ // Look for duplicate record numbers.
1075
+ for (const records of this.registry.numberToRecords.values()) {
1076
+ if (records.length <= 1) {
1077
+ continue;
1078
+ }
1079
+ const pushError = (recordKey: RecordKey, otherKey: RecordKey): void => {
1080
+ const record = this.registry.recordMap.get(recordKey)!;
1081
+ const other = this.registry.recordMap.get(otherKey)!;
1082
+
1083
+ const modulePath = record.record.name.line.modulePath;
1084
+ const moduleResult = modules.get(modulePath)!;
1085
+ const otherRecordName = other.record.name.text;
1086
+ const otherModulePath = other.modulePath;
1087
+ moduleResult.errors.push({
1088
+ token: record.record.name,
1089
+ message: `Same number as ${otherRecordName} in ${otherModulePath}`,
1090
+ });
1091
+ };
1092
+ pushError(records[0]!, records[1]!);
1093
+ for (let i = 1; i < records.length; ++i) {
1094
+ pushError(records[i]!, records[0]!);
1095
+ }
1025
1096
  }
1026
- for (const [key, value] of other.numberToMethod.entries()) {
1027
- this.numberToMethod.set(key, value);
1097
+
1098
+ // Aggregate errors across all modules.
1099
+ const errors: SkirError[] = [];
1100
+ for (const moduleBundle of modules.values()) {
1101
+ errors.push(...moduleBundle.errors);
1028
1102
  }
1029
- this.mutableErrors.push(...other.errors);
1103
+ return {
1104
+ modules: modules,
1105
+ errors: errors,
1106
+ };
1030
1107
  }
1031
1108
 
1032
- private readonly mutableModules = new Map<string, Result<Module | null>>();
1033
- private readonly mutableRecordMap = new Map<RecordKey, RecordLocation>();
1034
- private readonly mutableResolvedModules: Module[] = [];
1035
- private readonly numberToRecord = new Map<number, RecordKey>();
1036
- private readonly numberToMethod = new Map<number, Method>();
1037
- private readonly mutableErrors: SkirError[] = [];
1109
+ // BEGIN PROPERTIES
1110
+ private cache: Cache | undefined;
1111
+ private readonly moduleBundles = new Map<string, ModuleBundle>();
1112
+ private readonly registry = new DeclarationRegistry();
1113
+ private readonly finalizationResult: FinalizationResult;
1114
+ // END PROPERTIES
1038
1115
 
1039
- get modules(): ReadonlyMap<string, Result<Module | null>> {
1040
- return this.mutableModules;
1041
- }
1042
-
1043
- get recordMap(): ReadonlyMap<RecordKey, RecordLocation> {
1044
- return this.mutableRecordMap;
1116
+ findRecordByNumber(recordNumber: number): RecordLocation | undefined {
1117
+ const { numberToRecords, recordMap } = this.registry;
1118
+ const recordKeys = numberToRecords.get(recordNumber);
1119
+ return recordKeys?.length === 1 ? recordMap.get(recordKeys[0]!) : undefined;
1045
1120
  }
1046
1121
 
1047
- get resolvedModules(): ReadonlyArray<Module> {
1048
- return this.mutableResolvedModules;
1122
+ findMethodByNumber(methodNumber: number): Method | undefined {
1123
+ const { numberToMethods } = this.registry;
1124
+ const methods = numberToMethods.get(methodNumber);
1125
+ return methods?.length === 1 ? methods[0]! : undefined;
1049
1126
  }
1050
1127
 
1051
- findRecordByNumber(recordNumber: number): RecordLocation | undefined {
1052
- const recordKey = this.numberToRecord.get(recordNumber);
1053
- if (recordKey === undefined) {
1054
- return undefined;
1055
- }
1056
- return this.recordMap.get(recordKey);
1128
+ get recordMap(): ReadonlyMap<RecordKey, RecordLocation> {
1129
+ return this.registry.recordMap;
1057
1130
  }
1058
1131
 
1059
- findMethodByNumber(methodNumber: number): Method | undefined {
1060
- return this.numberToMethod.get(methodNumber);
1132
+ get errors(): readonly SkirError[] {
1133
+ return this.finalizationResult.errors;
1061
1134
  }
1062
1135
 
1063
- get errors(): readonly SkirError[] {
1064
- return this.mutableErrors;
1136
+ get modules(): ReadonlyMap<string, Result<Module>> {
1137
+ return this.finalizationResult.modules;
1065
1138
  }
1066
1139
  }
1067
1140
 
@@ -1150,7 +1223,7 @@ function validateKeyedItems(
1150
1223
  class TypeResolver {
1151
1224
  constructor(
1152
1225
  private readonly module: Module,
1153
- private readonly modules: Map<string, Result<Module | null>>,
1226
+ private readonly moduleBundles: Map<string, ModuleBundle>,
1154
1227
  private readonly usedImports: Set<string>,
1155
1228
  private readonly errors: ErrorSink,
1156
1229
  ) {}
@@ -1196,7 +1269,7 @@ class TypeResolver {
1196
1269
  // reference, or the module if the record reference is absolute (starts with
1197
1270
  // a dot).
1198
1271
  let start: Record | Module | undefined;
1199
- const { errors, module, modules, usedImports } = this;
1272
+ const { errors, module, moduleBundles: modules, usedImports } = this;
1200
1273
  if (recordOrigin !== "top-level") {
1201
1274
  if (!recordRef.absolute) {
1202
1275
  // Traverse the chain of ancestors from most nested to top-level.
@@ -1269,12 +1342,12 @@ class TypeResolver {
1269
1342
  return undefined;
1270
1343
  }
1271
1344
  const newModuleResult = modules.get(newModulePath);
1272
- if (newModuleResult === undefined || newModuleResult.result === null) {
1345
+ if (!newModuleResult) {
1273
1346
  // The module was not found or has errors: an error was already
1274
1347
  // registered, no need to register a new one.
1275
1348
  return undefined;
1276
1349
  }
1277
- const newModule = newModuleResult.result;
1350
+ const newModule = newModuleResult.module.result;
1278
1351
  if (newIt.kind === "import") {
1279
1352
  newIt = newModule.nameToDeclaration[name];
1280
1353
  if (!newIt) {
@@ -1322,6 +1395,145 @@ class TypeResolver {
1322
1395
  }
1323
1396
  }
1324
1397
 
1398
+ type ModuleCacheResult =
1399
+ | {
1400
+ kind: "no-cache";
1401
+ }
1402
+ | {
1403
+ kind: "module-tokens";
1404
+ tokens: Result<ModuleTokens>;
1405
+ }
1406
+ | {
1407
+ kind: "module-bundle";
1408
+ bundle: ModuleBundle;
1409
+ };
1410
+
1411
+ class Cache {
1412
+ private readonly modulePathToCacheResult = new Map<
1413
+ string,
1414
+ ModuleCacheResult
1415
+ >();
1416
+
1417
+ constructor(
1418
+ modulePathToNewContent: ReadonlyMap<string, string>,
1419
+ private readonly modulePathToOldBundle: ReadonlyMap<string, ModuleBundle>,
1420
+ ) {
1421
+ const unchangedModulePaths = new Set<string>();
1422
+ for (const [modulePath, newContent] of modulePathToNewContent) {
1423
+ const oldBundle = modulePathToOldBundle.get(modulePath);
1424
+ if (oldBundle?.tokens.result.sourceCode === newContent) {
1425
+ unchangedModulePaths.add(modulePath);
1426
+ }
1427
+ }
1428
+ const classify = (modulePath: string): ModuleCacheResult => {
1429
+ const oldBundle = modulePathToOldBundle.get(modulePath);
1430
+ if (!oldBundle) {
1431
+ return { kind: "no-cache" };
1432
+ }
1433
+ {
1434
+ const inMap = this.modulePathToCacheResult.get(modulePath);
1435
+ if (inMap) {
1436
+ return inMap;
1437
+ }
1438
+ }
1439
+ let result: ModuleCacheResult;
1440
+ const newContent = modulePathToNewContent.get(modulePath);
1441
+ if (newContent === oldBundle.tokens.result.sourceCode) {
1442
+ // Assume best case, may downgrade later.
1443
+ this.modulePathToCacheResult.set(modulePath, {
1444
+ kind: "module-bundle",
1445
+ bundle: oldBundle,
1446
+ });
1447
+ const directDependencies = Object.keys(
1448
+ oldBundle.module.result?.pathToImportedNames,
1449
+ );
1450
+ result = directDependencies.every(
1451
+ (dep) => classify(dep).kind === "module-bundle",
1452
+ )
1453
+ ? { kind: "module-bundle", bundle: oldBundle }
1454
+ : { kind: "module-tokens", tokens: oldBundle.tokens };
1455
+ } else {
1456
+ result = { kind: "no-cache" };
1457
+ }
1458
+ this.modulePathToCacheResult.set(modulePath, result);
1459
+ return result;
1460
+ };
1461
+ for (const modulePath of modulePathToOldBundle.keys()) {
1462
+ classify(modulePath);
1463
+ }
1464
+ }
1465
+
1466
+ getModuleCacheResult(modulePath: string): ModuleCacheResult {
1467
+ return this.modulePathToCacheResult.get(modulePath) ?? { kind: "no-cache" };
1468
+ }
1469
+ }
1470
+
1471
+ /** Registry of declarations possibly across multiple modules. */
1472
+ class DeclarationRegistry {
1473
+ readonly recordMap = new Map<RecordKey, RecordLocation>();
1474
+ readonly numberToRecords = new Map<number, RecordKey[]>();
1475
+ readonly numberToMethods = new Map<number, Method[]>();
1476
+
1477
+ mergeFrom(other: DeclarationRegistry): void {
1478
+ for (const [key, value] of other.recordMap) {
1479
+ this.recordMap.set(key, value);
1480
+ }
1481
+ for (const [number, value] of other.numberToRecords) {
1482
+ let existing = this.numberToRecords.get(number);
1483
+ if (!existing) {
1484
+ existing = [];
1485
+ this.numberToRecords.set(number, existing);
1486
+ }
1487
+ existing.push(...value);
1488
+ }
1489
+ for (const [number, value] of other.numberToMethods) {
1490
+ let existing = this.numberToMethods.get(number);
1491
+ if (!existing) {
1492
+ existing = [];
1493
+ this.numberToMethods.set(number, existing);
1494
+ }
1495
+ existing.push(...value);
1496
+ }
1497
+ }
1498
+
1499
+ pushNumberRecord(number: number, record: RecordKey): void {
1500
+ let value = this.numberToRecords.get(number);
1501
+ if (!value) {
1502
+ value = [];
1503
+ this.numberToRecords.set(number, value);
1504
+ }
1505
+ value.push(record);
1506
+ }
1507
+
1508
+ pushNumberMethod(number: number, method: Method): void {
1509
+ let value = this.numberToMethods.get(number);
1510
+ if (!value) {
1511
+ value = [];
1512
+ this.numberToMethods.set(number, value);
1513
+ }
1514
+ value.push(method);
1515
+ }
1516
+ }
1517
+
1518
+ class ModuleBundle {
1519
+ constructor(
1520
+ readonly tokens: Result<ModuleTokens>,
1521
+ readonly module: Result<Module>,
1522
+ ) {}
1523
+
1524
+ /**
1525
+ * Registry of declarations found in this module only.
1526
+ * Will be merged into the "global" registry.
1527
+ */
1528
+ readonly registry = new DeclarationRegistry();
1529
+ }
1530
+
1531
+ interface FinalizationResult {
1532
+ readonly modules: ReadonlyMap<string, Result<Module>>;
1533
+ /** Errors aggregated across all modules. */
1534
+ readonly errors: readonly SkirError[];
1535
+ }
1536
+
1325
1537
  function ensureAllImportsAreUsed(
1326
1538
  module: Module,
1327
1539
  usedImports: Set<string>,
@@ -1348,55 +1560,94 @@ function ensureAllImportsAreUsed(
1348
1560
  }
1349
1561
  }
1350
1562
 
1351
- export interface ModuleParser {
1352
- parseModule(modulePath: string): Result<MutableModule | null>;
1353
- }
1354
-
1355
- abstract class ModuleParserBase implements ModuleParser {
1356
- abstract readSourceCode(modulePath: string): string | undefined;
1357
-
1358
- parseModule(modulePath: string): Result<MutableModule | null> {
1359
- const code = this.readSourceCode(modulePath);
1360
- if (code === undefined) {
1361
- return {
1362
- result: null,
1363
- errors: [],
1364
- };
1365
- }
1366
-
1367
- const tokens = tokenizeModule(code, modulePath);
1368
- if (tokens.errors.length !== 0) {
1369
- return {
1370
- result: null,
1371
- errors: tokens.errors,
1372
- };
1373
- }
1374
-
1375
- return parseModule(tokens.result, "strict");
1376
- }
1377
- }
1378
-
1379
- class DefaultModuleParser extends ModuleParserBase {
1380
- constructor(
1381
- private readonly fileReader: FileReader,
1382
- private readonly rootPath: string,
1383
- ) {
1384
- super();
1563
+ /**
1564
+ * Returns suggested module paths for import auto-completion.
1565
+ *
1566
+ * Given the partial path the user has typed, returns suggestions from
1567
+ * `modulePathToContent`. For paths where there is more after the matched
1568
+ * segment, the suggestion is truncated at the next "/" and a trailing "/" is
1569
+ * appended to signal that it is a directory, not a file.
1570
+ *
1571
+ * Handles both absolute paths (e.g. "bb/ee") and relative paths (e.g.
1572
+ * "./other", "../sibling").
1573
+ */
1574
+ function suggestModulePaths(
1575
+ typedPath: string,
1576
+ originModulePath: string,
1577
+ modulePathToContent: ReadonlyMap<string, string>,
1578
+ ): ReadonlyArray<{ readonly name: string }> {
1579
+ const isRelative = typedPath.startsWith("./") || typedPath.startsWith("../");
1580
+
1581
+ // Compute the absolute path prefix to match against all module paths.
1582
+ let absolutePrefix: string;
1583
+ if (isRelative) {
1584
+ // Split at the last "/" so that the directory component (fully typed) can
1585
+ // be resolved cleanly, while the tail (partial filename/dir prefix) is
1586
+ // appended afterwards.
1587
+ const lastSlash = typedPath.lastIndexOf("/");
1588
+ const dirComponent = typedPath.slice(0, lastSlash + 1);
1589
+ const filePrefix = typedPath.slice(lastSlash + 1);
1590
+ // Append a dummy filename so Paths.join/normalize treat the directory
1591
+ // component as a file path (avoids trailing-slash edge-cases), then strip
1592
+ // it off again.
1593
+ const dummy = dirComponent + "__dummy__";
1594
+ const resolvedDummy = Paths.join(
1595
+ Paths.dirname(originModulePath),
1596
+ dummy.startsWith("./") ? dummy.slice(2) : dummy,
1597
+ ).replace(/\\/g, "/");
1598
+ const absoluteDir = resolvedDummy.slice(
1599
+ 0,
1600
+ resolvedDummy.length - "__dummy__".length,
1601
+ );
1602
+ absolutePrefix = absoluteDir + filePrefix;
1603
+ } else if (originModulePath.startsWith("@") && !typedPath.startsWith("@")) {
1604
+ // Mirror the package-prefix logic from resolveModulePath.
1605
+ absolutePrefix = extractPackagePrefix(originModulePath) + typedPath;
1606
+ } else {
1607
+ absolutePrefix = typedPath;
1385
1608
  }
1386
-
1387
- readSourceCode(modulePath: string): string | undefined {
1388
- return this.fileReader.readTextFile(Paths.join(this.rootPath, modulePath));
1609
+ absolutePrefix = absolutePrefix.replace(/\\/g, "/");
1610
+ if (absolutePrefix.startsWith("../")) {
1611
+ // Typed path escapes the root; no valid suggestions.
1612
+ return [];
1389
1613
  }
1390
- }
1391
1614
 
1392
- class MapBasedModuleParser extends ModuleParserBase {
1393
- constructor(private readonly moduleMap: ReadonlyMap<string, string>) {
1394
- super();
1615
+ const originBaseDir = Paths.dirname(originModulePath).replace(/\\/g, "/");
1616
+ const suggestions = new Set<string>();
1617
+
1618
+ for (const path of modulePathToContent.keys()) {
1619
+ if (!path.startsWith(absolutePrefix)) continue;
1620
+ const remaining = path.slice(absolutePrefix.length);
1621
+ const slashIndex = remaining.indexOf("/");
1622
+ // If there is a sub-path after the matched prefix, collapse to the next
1623
+ // directory segment and append "/". Otherwise use the full path.
1624
+ const absoluteSuggestion =
1625
+ slashIndex >= 0
1626
+ ? absolutePrefix + remaining.slice(0, slashIndex + 1)
1627
+ : path;
1628
+
1629
+ if (isRelative) {
1630
+ const isDir = absoluteSuggestion.endsWith("/");
1631
+ const absPath = isDir
1632
+ ? absoluteSuggestion.slice(0, -1)
1633
+ : absoluteSuggestion;
1634
+ let rel = Paths.relative(originBaseDir, absPath).replace(/\\/g, "/");
1635
+ // Paths.relative returns "" when both paths are the same; normalise to
1636
+ // "." so the subsequent prefix-check and directory-slash logic work
1637
+ // correctly (avoids producing the invalid path ".//" for same-dir cases).
1638
+ if (rel === "") {
1639
+ rel = ".";
1640
+ }
1641
+ if (!rel.startsWith(".")) {
1642
+ rel = "./" + rel;
1643
+ }
1644
+ suggestions.add(isDir ? rel + "/" : rel);
1645
+ } else {
1646
+ suggestions.add(absoluteSuggestion);
1647
+ }
1395
1648
  }
1396
1649
 
1397
- readSourceCode(modulePath: string): string | undefined {
1398
- return this.moduleMap.get(modulePath);
1399
- }
1650
+ return [...suggestions].map((name) => ({ name }));
1400
1651
  }
1401
1652
 
1402
1653
  function resolveModulePath(