skir 1.1.2 → 1.1.3

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