skir 0.0.1 → 0.0.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.
Files changed (115) hide show
  1. package/dist/casing.d.ts +1 -4
  2. package/dist/casing.d.ts.map +1 -1
  3. package/dist/casing.js +0 -29
  4. package/dist/casing.js.map +1 -1
  5. package/dist/casing.test.js +2 -13
  6. package/dist/casing.test.js.map +1 -1
  7. package/dist/command_line_parser.d.ts +3 -3
  8. package/dist/command_line_parser.d.ts.map +1 -1
  9. package/dist/command_line_parser.js +35 -38
  10. package/dist/command_line_parser.js.map +1 -1
  11. package/dist/command_line_parser.test.js +73 -78
  12. package/dist/command_line_parser.test.js.map +1 -1
  13. package/dist/compatibility_checker.d.ts +9 -3
  14. package/dist/compatibility_checker.d.ts.map +1 -1
  15. package/dist/compatibility_checker.js +17 -4
  16. package/dist/compatibility_checker.js.map +1 -1
  17. package/dist/compatibility_checker.test.js +55 -1
  18. package/dist/compatibility_checker.test.js.map +1 -1
  19. package/dist/compiler.js +34 -17
  20. package/dist/compiler.js.map +1 -1
  21. package/dist/config.d.ts +5 -35
  22. package/dist/config.d.ts.map +1 -1
  23. package/dist/definition_finder.d.ts +1 -1
  24. package/dist/definition_finder.d.ts.map +1 -1
  25. package/dist/doc_comment_parser.d.ts +3 -0
  26. package/dist/doc_comment_parser.d.ts.map +1 -0
  27. package/dist/doc_comment_parser.js +223 -0
  28. package/dist/doc_comment_parser.js.map +1 -0
  29. package/dist/doc_comment_parser.test.d.ts +2 -0
  30. package/dist/doc_comment_parser.test.d.ts.map +1 -0
  31. package/dist/doc_comment_parser.test.js +496 -0
  32. package/dist/doc_comment_parser.test.js.map +1 -0
  33. package/dist/error_renderer.d.ts +1 -1
  34. package/dist/error_renderer.d.ts.map +1 -1
  35. package/dist/error_renderer.js +84 -65
  36. package/dist/error_renderer.js.map +1 -1
  37. package/dist/formatter.d.ts +15 -2
  38. package/dist/formatter.d.ts.map +1 -1
  39. package/dist/formatter.js +191 -234
  40. package/dist/formatter.js.map +1 -1
  41. package/dist/formatter.test.js +322 -88
  42. package/dist/formatter.test.js.map +1 -1
  43. package/dist/index.d.ts +1 -5
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +1 -4
  46. package/dist/index.js.map +1 -1
  47. package/dist/language_server.js +1 -1
  48. package/dist/language_server.js.map +1 -1
  49. package/dist/literals.d.ts +1 -2
  50. package/dist/literals.d.ts.map +1 -1
  51. package/dist/literals.js +1 -12
  52. package/dist/literals.js.map +1 -1
  53. package/dist/literals.test.js +1 -4
  54. package/dist/literals.test.js.map +1 -1
  55. package/dist/module_set.d.ts +3 -7
  56. package/dist/module_set.d.ts.map +1 -1
  57. package/dist/module_set.js +205 -51
  58. package/dist/module_set.js.map +1 -1
  59. package/dist/module_set.test.js +595 -28
  60. package/dist/module_set.test.js.map +1 -1
  61. package/dist/parser.d.ts +3 -4
  62. package/dist/parser.d.ts.map +1 -1
  63. package/dist/parser.js +186 -92
  64. package/dist/parser.js.map +1 -1
  65. package/dist/parser.test.js +243 -15
  66. package/dist/parser.test.js.map +1 -1
  67. package/dist/project_initializer.d.ts +2 -0
  68. package/dist/project_initializer.d.ts.map +1 -0
  69. package/dist/project_initializer.js +30 -0
  70. package/dist/project_initializer.js.map +1 -0
  71. package/dist/snapshotter.d.ts +3 -0
  72. package/dist/snapshotter.d.ts.map +1 -1
  73. package/dist/snapshotter.js +43 -6
  74. package/dist/snapshotter.js.map +1 -1
  75. package/dist/tokenizer.d.ts +8 -2
  76. package/dist/tokenizer.d.ts.map +1 -1
  77. package/dist/tokenizer.js +26 -20
  78. package/dist/tokenizer.js.map +1 -1
  79. package/dist/tokenizer.test.js +285 -269
  80. package/dist/tokenizer.test.js.map +1 -1
  81. package/package.json +7 -5
  82. package/src/casing.ts +1 -36
  83. package/src/command_line_parser.ts +42 -48
  84. package/src/compatibility_checker.ts +29 -7
  85. package/src/compiler.ts +35 -18
  86. package/src/definition_finder.ts +1 -1
  87. package/src/doc_comment_parser.ts +251 -0
  88. package/src/error_renderer.ts +90 -66
  89. package/src/formatter.ts +249 -238
  90. package/src/index.ts +0 -6
  91. package/src/language_server.ts +8 -8
  92. package/src/literals.ts +5 -14
  93. package/src/module_set.ts +259 -79
  94. package/src/parser.ts +214 -98
  95. package/src/project_initializer.ts +39 -0
  96. package/src/snapshotter.ts +46 -5
  97. package/src/tokenizer.ts +47 -25
  98. package/dist/encoding.d.ts +0 -2
  99. package/dist/encoding.d.ts.map +0 -1
  100. package/dist/encoding.js +0 -38
  101. package/dist/encoding.js.map +0 -1
  102. package/dist/encoding.test.d.ts +0 -2
  103. package/dist/encoding.test.d.ts.map +0 -1
  104. package/dist/encoding.test.js +0 -23
  105. package/dist/encoding.test.js.map +0 -1
  106. package/dist/index.test.d.ts +0 -2
  107. package/dist/index.test.d.ts.map +0 -1
  108. package/dist/index.test.js +0 -14
  109. package/dist/index.test.js.map +0 -1
  110. package/dist/types.d.ts +0 -375
  111. package/dist/types.d.ts.map +0 -1
  112. package/dist/types.js +0 -2
  113. package/dist/types.js.map +0 -1
  114. package/src/encoding.ts +0 -32
  115. package/src/types.ts +0 -518
package/src/casing.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Casing, ErrorSink, Token } from "./types.js";
1
+ import type { ErrorSink, Token } from "skir-internal";
2
2
 
3
3
  /** Registers an error if the given token does not match the expected casing. */
4
4
  export function validate(
@@ -14,41 +14,6 @@ export function validate(
14
14
  }
15
15
  }
16
16
 
17
- export function convertCase(
18
- text: string,
19
- source: Casing,
20
- target: Casing,
21
- ): string {
22
- let words: string[];
23
- switch (source) {
24
- case "lowerCamel":
25
- case "UpperCamel":
26
- words = text.split(/(?=[A-Z])/).map((w) => w.toLowerCase());
27
- break;
28
- case "lower_underscore":
29
- words = text.split("_");
30
- break;
31
- case "UPPER_UNDERSCORE":
32
- words = text.split("_").map((w) => w.toLowerCase());
33
- break;
34
- }
35
- switch (target) {
36
- case "lowerCamel":
37
- return words.map((w, i) => (i ? capitalize(w) : w)).join("");
38
- case "lower_underscore":
39
- return words.join("_");
40
- case "UpperCamel":
41
- return words.map(capitalize).join("");
42
- case "UPPER_UNDERSCORE":
43
- return words.map((w) => w.toUpperCase()).join("_");
44
- }
45
- }
46
-
47
- /** Returns a new string with the first letter of `name` capitalized. */
48
- export function capitalize(name: string): string {
49
- return name[0]!.toUpperCase() + name.slice(1);
50
- }
51
-
52
17
  export function caseMatches(
53
18
  name: string,
54
19
  expected: "lower_underscore" | "UpperCamel" | "UPPER_UNDERSCORE",
@@ -1,18 +1,18 @@
1
1
  export type ParsedArgs =
2
2
  | {
3
3
  kind: "gen";
4
+ subcommand?: "watch";
4
5
  root?: string;
5
- watch?: true;
6
6
  }
7
7
  | {
8
8
  kind: "format";
9
+ subcommand?: "check";
9
10
  root?: string;
10
- check?: true;
11
11
  }
12
12
  | {
13
13
  kind: "snapshot";
14
+ subcommand?: "check" | "view";
14
15
  root?: string;
15
- check?: true;
16
16
  }
17
17
  | {
18
18
  kind: "init";
@@ -80,26 +80,30 @@ export function parseCommandLine(args: string[]): ParsedArgs {
80
80
  const COMMAND_BASE = "npx skir";
81
81
 
82
82
  const HELP_TEXT = `
83
- Usage: ${COMMAND_BASE} <command> [options]
83
+ Usage: ${COMMAND_BASE} <command> [subcommand] [options]
84
84
 
85
85
  Commands:
86
- gen Generate code from Skir source files to target languages
87
- format Format all .skir files in the specified directory
88
- snapshot Verify compatibility by comparing current .skir files against the last snapshot
89
- init Initialize a new Skir project with a minimal skir.yml file
90
- help Display this help message
86
+ gen [watch] Generate code from Skir source files to target languages
87
+ watch: Automatically regenerate when .skir files change
88
+ format [check] Format all .skir files in the specified directory
89
+ check: Fail if code is not properly formatted
90
+ snapshot [check|view] Manage .skir file snapshots for compatibility checking
91
+ check: Fail if there are breaking changes since last snapshot
92
+ view: Display the last snapshot
93
+ init Initialize a new Skir project with a minimal skir.yml file
94
+ help Display this help message
91
95
 
92
96
  Options:
93
97
  --root, -r <path> Path to the directory containing the skir.yml configuration file
94
- --watch, -w Enable watch mode to automatically regenerate code when .skir files change (gen only)
95
- --check, -c Check mode: fail if code is not properly formatted (format) or if there are breaking changes (snapshot)
96
98
 
97
99
  Examples:
98
100
  ${COMMAND_BASE} gen
101
+ ${COMMAND_BASE} gen watch
99
102
  ${COMMAND_BASE} format --root path/to/root/dir
100
- ${COMMAND_BASE} format -r path/to/root/dir
101
- ${COMMAND_BASE} gen -r path/to/root/dir --watch
102
- ${COMMAND_BASE} snapshot --root path/to/root/dir
103
+ ${COMMAND_BASE} format check -r path/to/root/dir
104
+ ${COMMAND_BASE} snapshot
105
+ ${COMMAND_BASE} snapshot check
106
+ ${COMMAND_BASE} snapshot view --root path/to/root/dir
103
107
  `;
104
108
 
105
109
  export class CommandLineParseError extends Error {
@@ -111,8 +115,7 @@ export class CommandLineParseError extends Error {
111
115
 
112
116
  type ParsedOptions = {
113
117
  root?: string;
114
- watch?: boolean;
115
- check?: boolean;
118
+ subcommand?: string;
116
119
  unknown: string[];
117
120
  };
118
121
 
@@ -136,24 +139,14 @@ function parseOptions(args: string[]): ParsedOptions {
136
139
  }
137
140
  options.root = args[i + 1];
138
141
  i++; // Skip the next argument as it's the value
139
- } else if (arg === "--watch" || arg === "-w") {
140
- if (options.watch) {
141
- throw new CommandLineParseError(
142
- `Option ${arg} specified multiple times`,
143
- );
144
- }
145
- options.watch = true;
146
- } else if (arg === "--check" || arg === "-c") {
147
- if (options.check) {
148
- throw new CommandLineParseError(
149
- `Option ${arg} specified multiple times`,
150
- );
151
- }
152
- options.check = true;
153
142
  } else if (arg.startsWith("-")) {
154
143
  options.unknown.push(arg);
155
144
  } else {
156
- throw new CommandLineParseError(`Unexpected argument: ${arg}`);
145
+ // Positional argument - treat as subcommand
146
+ if (options.subcommand !== undefined) {
147
+ throw new CommandLineParseError(`Unexpected argument: ${arg}`);
148
+ }
149
+ options.subcommand = arg;
157
150
  }
158
151
  }
159
152
 
@@ -163,63 +156,64 @@ function parseOptions(args: string[]): ParsedOptions {
163
156
  function buildGenCommand(options: ParsedOptions): ParsedArgs {
164
157
  validateNoUnknownOptions(options, "gen");
165
158
 
166
- if (options.check) {
159
+ if (options.subcommand !== undefined && options.subcommand !== "watch") {
167
160
  throw new CommandLineParseError(
168
- "Option --check is not valid for the 'gen' command",
161
+ `Unknown subcommand for 'gen': ${options.subcommand}`,
169
162
  );
170
163
  }
171
164
 
172
165
  return {
173
166
  kind: "gen",
174
167
  root: options.root,
175
- watch: options.watch ? true : undefined,
168
+ subcommand: options.subcommand === "watch" ? "watch" : undefined,
176
169
  };
177
170
  }
178
171
 
179
172
  function buildFormatCommand(options: ParsedOptions): ParsedArgs {
180
173
  validateNoUnknownOptions(options, "format");
181
174
 
182
- if (options.watch) {
175
+ if (options.subcommand !== undefined && options.subcommand !== "check") {
183
176
  throw new CommandLineParseError(
184
- "Option --watch is not valid for the 'format' command",
177
+ `Unknown subcommand for 'format': ${options.subcommand}`,
185
178
  );
186
179
  }
187
180
 
188
181
  return {
189
182
  kind: "format",
190
183
  root: options.root,
191
- check: options.check ? true : undefined,
184
+ subcommand: options.subcommand === "check" ? "check" : undefined,
192
185
  };
193
186
  }
194
187
 
195
188
  function buildSnapshotCommand(options: ParsedOptions): ParsedArgs {
196
189
  validateNoUnknownOptions(options, "snapshot");
197
190
 
198
- if (options.watch) {
191
+ if (
192
+ options.subcommand !== undefined &&
193
+ options.subcommand !== "check" &&
194
+ options.subcommand !== "view"
195
+ ) {
199
196
  throw new CommandLineParseError(
200
- "Option --watch is not valid for the 'snapshot' command",
197
+ `Unknown subcommand for 'snapshot': ${options.subcommand}`,
201
198
  );
202
199
  }
203
200
 
204
201
  return {
205
202
  kind: "snapshot",
206
203
  root: options.root,
207
- check: options.check ? true : undefined,
204
+ subcommand:
205
+ options.subcommand === "check" || options.subcommand === "view"
206
+ ? options.subcommand
207
+ : undefined,
208
208
  };
209
209
  }
210
210
 
211
211
  function buildInitCommand(options: ParsedOptions): ParsedArgs {
212
212
  validateNoUnknownOptions(options, "init");
213
213
 
214
- if (options.watch) {
215
- throw new CommandLineParseError(
216
- "Option --watch is not valid for the 'init' command",
217
- );
218
- }
219
-
220
- if (options.check) {
214
+ if (options.subcommand !== undefined) {
221
215
  throw new CommandLineParseError(
222
- "Option --check is not valid for the 'init' command",
216
+ `Unknown subcommand for 'init': ${options.subcommand}`,
223
217
  );
224
218
  }
225
219
 
@@ -1,4 +1,3 @@
1
- import { ModuleSet } from "./module_set.js";
2
1
  import type {
3
2
  Field,
4
3
  Method,
@@ -7,7 +6,8 @@ import type {
7
6
  RecordLocation,
8
7
  ResolvedType,
9
8
  Token,
10
- } from "./types.js";
9
+ } from "skir-internal";
10
+ import { ModuleSet } from "./module_set.js";
11
11
 
12
12
  export interface BeforeAfter<T> {
13
13
  before: T;
@@ -50,7 +50,14 @@ export type BreakingChange =
50
50
  reintroducedAs: Token;
51
51
  }
52
52
  | {
53
- kind: "enum-variant-kind-change";
53
+ kind: "missing-variant";
54
+ record: BeforeAfter<RecordLocation>;
55
+ enumEpression: BeforeAfter<Expression>;
56
+ variantName: Token;
57
+ number: number;
58
+ }
59
+ | {
60
+ kind: "variant-kind-change";
54
61
  record: BeforeAfter<RecordLocation>;
55
62
  enumEpression: BeforeAfter<Expression>;
56
63
  variantName: BeforeAfter<Token>;
@@ -210,9 +217,21 @@ class BackwardCompatibilityChecker {
210
217
  });
211
218
  }
212
219
  }
220
+ const removedNumbersAfter = new Set<number>(
221
+ record.after.record.removedNumbers,
222
+ );
213
223
  for (const fieldBefore of record.before.record.fields) {
214
224
  const fieldAfter = numberToFieldAfter.get(fieldBefore.number);
215
225
  if (fieldAfter === undefined) {
226
+ if (!removedNumbersAfter.has(fieldBefore.number)) {
227
+ this.pushBreakingChange({
228
+ kind: "missing-variant",
229
+ record,
230
+ enumEpression: recordExpression,
231
+ variantName: fieldBefore.name,
232
+ number: fieldBefore.number,
233
+ });
234
+ }
216
235
  continue;
217
236
  }
218
237
  if (fieldBefore.type && fieldAfter.type) {
@@ -249,7 +268,7 @@ class BackwardCompatibilityChecker {
249
268
  );
250
269
  } else if (fieldBefore.type || fieldAfter.type) {
251
270
  this.pushBreakingChange({
252
- kind: "enum-variant-kind-change",
271
+ kind: "variant-kind-change",
253
272
  record,
254
273
  enumEpression: recordExpression,
255
274
  variantName: {
@@ -362,7 +381,7 @@ class BackwardCompatibilityChecker {
362
381
  private readonly seenRecordKeys = new Set<string>();
363
382
  }
364
383
 
365
- function getTokenForBreakingChange(
384
+ export function getTokenForBreakingChange(
366
385
  breakingChange: BreakingChange,
367
386
  ): Token | null {
368
387
  switch (breakingChange.kind) {
@@ -371,7 +390,10 @@ function getTokenForBreakingChange(
371
390
  }
372
391
  case "missing-slots":
373
392
  case "record-kind-change":
374
- case "enum-variant-kind-change": {
393
+ case "variant-kind-change": {
394
+ return breakingChange.record.after.record.name;
395
+ }
396
+ case "missing-variant": {
375
397
  return breakingChange.record.after.record.name;
376
398
  }
377
399
  case "removed-number-reintroduced": {
@@ -384,7 +406,7 @@ function getTokenForBreakingChange(
384
406
  }
385
407
  }
386
408
 
387
- export function getTokenForExpression(expression: Expression): Token {
409
+ function getTokenForExpression(expression: Expression): Token {
388
410
  switch (expression.kind) {
389
411
  case "item": {
390
412
  return getTokenForExpression(expression.arrayExpression);
package/src/compiler.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  import * as fs from "fs/promises";
3
3
  import { glob } from "glob";
4
4
  import * as paths from "path";
5
+ import type { CodeGenerator } from "skir-internal";
5
6
  import Watcher from "watcher";
6
7
  import * as yaml from "yaml";
7
8
  import { fromZodError } from "zod-validation-error";
@@ -17,9 +18,9 @@ import { formatModule } from "./formatter.js";
17
18
  import { REAL_FILE_SYSTEM } from "./io.js";
18
19
  import { collectModules } from "./module_collector.js";
19
20
  import { ModuleSet } from "./module_set.js";
20
- import { takeSnapshot } from "./snapshotter.js";
21
+ import { initializeProject } from "./project_initializer.js";
22
+ import { takeSnapshot, viewSnapshot } from "./snapshotter.js";
21
23
  import { tokenizeModule } from "./tokenizer.js";
22
- import type { CodeGenerator } from "./types.js";
23
24
 
24
25
  interface GeneratorBundle<Config = unknown> {
25
26
  generator: CodeGenerator<Config>;
@@ -136,6 +137,9 @@ class WatchModeMainLoop {
136
137
  renderErrors(errors);
137
138
  return false;
138
139
  } else {
140
+ if (moduleSet.recordMap.size <= 0) {
141
+ console.error(makeRed("No skir modules found in source directory"));
142
+ }
139
143
  await this.doGenerate(moduleSet);
140
144
  if (this.watchModeOn) {
141
145
  const date = new Date().toLocaleTimeString("en-US");
@@ -291,12 +295,12 @@ async function format(root: string, mode: "fix" | "check"): Promise<void> {
291
295
  if (unformattedCode === undefined) {
292
296
  throw new Error(`Cannot read ${skirFile.fullpath()}`);
293
297
  }
294
- const tokens = tokenizeModule(unformattedCode, "", "keep-comments");
298
+ const tokens = tokenizeModule(unformattedCode, "");
295
299
  if (tokens.errors.length) {
296
300
  renderErrors(tokens.errors);
297
301
  process.exit(1);
298
302
  }
299
- const formattedCode = formatModule(tokens.result);
303
+ const formattedCode = formatModule(tokens.result).newSourceCode;
300
304
  pathToFormatResult.set(skirFile.fullpath(), {
301
305
  formattedCode: formattedCode,
302
306
  alreadyFormatted: formattedCode === unformattedCode,
@@ -347,6 +351,17 @@ async function main(): Promise<void> {
347
351
  process.exit(1);
348
352
  }
349
353
 
354
+ switch (args.kind) {
355
+ case "init": {
356
+ initializeProject(root!);
357
+ return;
358
+ }
359
+ case "help":
360
+ case "error": {
361
+ return;
362
+ }
363
+ }
364
+
350
365
  // Use an absolute path to make error messages more helpful.
351
366
  const skirConfigPath = paths.resolve(paths.join(root!, "skir.yml"));
352
367
  const skirConfigContents = REAL_FILE_SYSTEM.readTextFile(skirConfigPath);
@@ -375,7 +390,7 @@ async function main(): Promise<void> {
375
390
  switch (args.kind) {
376
391
  case "format": {
377
392
  // Check or fix the formatting to the .skir files in the source directory.
378
- await format(srcDir, args.check ? "check" : "fix");
393
+ await format(srcDir, args.subcommand === "check" ? "check" : "fix");
379
394
  break;
380
395
  }
381
396
  case "gen": {
@@ -399,7 +414,7 @@ async function main(): Promise<void> {
399
414
  process.exit(1);
400
415
  }
401
416
  }
402
- const watch = !!args.watch;
417
+ const watch = args.subcommand === "watch";
403
418
  const watchModeMainLoop = new WatchModeMainLoop(
404
419
  srcDir,
405
420
  generatorBundles,
@@ -413,21 +428,23 @@ async function main(): Promise<void> {
413
428
  }
414
429
  break;
415
430
  }
416
- case "init": {
417
- // TODO
418
- break;
419
- }
420
431
  case "snapshot": {
421
- takeSnapshot({
422
- rootDir: root!,
423
- srcDir: srcDir,
424
- check: !!args.check,
425
- });
432
+ if (args.subcommand === "view") {
433
+ viewSnapshot({
434
+ rootDir: root!,
435
+ });
436
+ } else {
437
+ takeSnapshot({
438
+ rootDir: root!,
439
+ srcDir: srcDir,
440
+ check: args.subcommand === "check",
441
+ });
442
+ }
426
443
  break;
427
444
  }
428
- case "help":
429
- case "error": {
430
- break;
445
+ default: {
446
+ const _: never = args;
447
+ throw new TypeError(_);
431
448
  }
432
449
  }
433
450
  }
@@ -2,7 +2,7 @@
2
2
  * Utilities to help implement the jump-to-definition functionality for skir
3
3
  * files in IDEs.
4
4
  */
5
- import type { Declaration, Module, ResolvedType, Token } from "./types.js";
5
+ import type { Declaration, Module, ResolvedType, Token } from "skir-internal";
6
6
 
7
7
  export interface DefinitionMatch {
8
8
  modulePath: string;
@@ -0,0 +1,251 @@
1
+ import { assert } from "node:console";
2
+ import type {
3
+ Doc,
4
+ DocPiece,
5
+ DocReference,
6
+ Result,
7
+ SkirError,
8
+ Token,
9
+ } from "skir-internal";
10
+
11
+ export function parseDocComments(docComments: readonly Token[]): Result<Doc> {
12
+ const parser = new DocCommentsParser(docComments);
13
+ return parser.parse();
14
+ }
15
+
16
+ class DocCommentsParser {
17
+ private readonly pieces: DocPiece[] = [];
18
+ private readonly errors: SkirError[] = [];
19
+ private currentText = "";
20
+ private docCommentIndex = -1;
21
+ private charIndex = -1;
22
+ private contentOffset = -1;
23
+
24
+ constructor(private readonly docComments: readonly Token[]) {}
25
+
26
+ parse(): Result<Doc> {
27
+ while (this.nextDocComment()) {
28
+ this.parseCurrentDocComment();
29
+ }
30
+
31
+ // Add any remaining text
32
+ if (this.currentText.length > 0) {
33
+ this.pieces.push({ kind: "text", text: this.currentText });
34
+ }
35
+
36
+ const text = this.docComments
37
+ .map((c) => c.text.slice(c.text.startsWith("/// ") ? 4 : 3))
38
+ .join("\n");
39
+
40
+ return {
41
+ result: {
42
+ text: text,
43
+ pieces: this.pieces,
44
+ },
45
+ errors: this.errors,
46
+ };
47
+ }
48
+
49
+ private parseCurrentDocComment(): void {
50
+ // Matches unescaped [ or ], OR escaped [[ or ]]
51
+ const specialCharRegex = /\[\[|\]\]|\[|\]/g;
52
+
53
+ while (this.charIndex < this.content.length) {
54
+ // Find next special character or escaped bracket
55
+ specialCharRegex.lastIndex = this.charIndex;
56
+ const match = specialCharRegex.exec(this.content);
57
+
58
+ if (!match) {
59
+ // No more special characters, add rest as text
60
+ this.currentText += this.content.slice(this.charIndex);
61
+ break;
62
+ }
63
+
64
+ // Add text before the special character
65
+ if (match.index > this.charIndex) {
66
+ this.currentText += this.content.slice(this.charIndex, match.index);
67
+ }
68
+
69
+ const matched = match[0];
70
+ this.charIndex = match.index;
71
+
72
+ if (matched === "[[") {
73
+ // Escaped left bracket
74
+ this.currentText += "[";
75
+ this.charIndex += 2;
76
+ } else if (matched === "]]") {
77
+ // Escaped right bracket
78
+ this.currentText += "]";
79
+ this.charIndex += 2;
80
+ } else if (matched === "[") {
81
+ // Start of a reference - save current text if any
82
+ if (this.currentText.length > 0) {
83
+ this.pieces.push({ kind: "text", text: this.currentText });
84
+ this.currentText = "";
85
+ }
86
+
87
+ // Parse the reference
88
+ const reference = this.parseReference();
89
+ this.pieces.push(reference);
90
+ } else if (matched === "]") {
91
+ // Unmatched right bracket - treat as text
92
+ this.currentText += matched;
93
+ this.charIndex++;
94
+ }
95
+ }
96
+
97
+ // Add newline between comment lines (except after the last line)
98
+ if (this.docCommentIndex < this.docComments.length - 1) {
99
+ this.currentText += "\n";
100
+ }
101
+ }
102
+
103
+ private parseReference(): DocReference {
104
+ const { content, docComment } = this;
105
+
106
+ const leftBracketCharIndex = this.charIndex;
107
+ const startPosition = docComment.position + leftBracketCharIndex;
108
+
109
+ const rightBracketCharIndex = content.indexOf("]", leftBracketCharIndex);
110
+
111
+ // End position: right after the closing bracket or at end of the line if
112
+ // not found.
113
+ const endCharIndex =
114
+ rightBracketCharIndex < 0 ? content.length : rightBracketCharIndex + 1;
115
+
116
+ const referenceText = content.slice(leftBracketCharIndex, endCharIndex);
117
+ const referenceRange: Token = {
118
+ text: referenceText,
119
+ originalText: referenceText,
120
+ position: startPosition,
121
+ line: docComment.line,
122
+ colNumber: startPosition - docComment.line.position,
123
+ };
124
+
125
+ let hasError = false;
126
+ if (rightBracketCharIndex < 0) {
127
+ hasError = true;
128
+ this.errors.push({
129
+ token: referenceRange,
130
+ message: "Unterminated reference",
131
+ });
132
+ }
133
+
134
+ // Move past the left bracket
135
+ this.charIndex++;
136
+
137
+ const wordRegex = /[a-zA-Z][_a-zA-Z0-9]*/g;
138
+
139
+ const tokens: Token[] = [];
140
+ while (this.charIndex < endCharIndex) {
141
+ const char = content[this.charIndex]!;
142
+ const position = docComment.position + this.charIndex;
143
+
144
+ const makeToken = (text: string): Token => ({
145
+ text: text,
146
+ originalText: text,
147
+ position: position,
148
+ line: docComment.line,
149
+ colNumber: position - docComment.line.position,
150
+ });
151
+
152
+ if (char === ".") {
153
+ // Dot token
154
+ tokens.push(makeToken("."));
155
+ this.charIndex++;
156
+ } else if (/^[a-zA-Z]/.test(char)) {
157
+ // Start of a word token - use regex to match the whole word
158
+ wordRegex.lastIndex = this.charIndex;
159
+ const match = wordRegex.exec(content);
160
+ const word = match![0];
161
+ tokens.push(makeToken(word));
162
+ this.charIndex += word.length;
163
+ } else if (char === "]") {
164
+ // Reached the end of the reference
165
+ tokens.push(makeToken("]"));
166
+ this.charIndex++;
167
+ } else {
168
+ // Invalid character in reference (including whitespace)
169
+ const column = this.docComment.colNumber + this.charIndex;
170
+ hasError = true;
171
+ this.errors.push({
172
+ token: referenceRange,
173
+ message: `Invalid character in reference at column ${column + 1}`,
174
+ });
175
+ // Exit loop
176
+ this.charIndex = endCharIndex;
177
+ }
178
+ }
179
+
180
+ const nameChain = hasError ? [] : this.parseNameChain(tokens);
181
+
182
+ return {
183
+ kind: "reference",
184
+ nameChain: nameChain,
185
+ absolute: tokens[0]?.text === ".",
186
+ referee: undefined,
187
+ docComment: this.docComment,
188
+ referenceRange: referenceRange,
189
+ };
190
+ }
191
+
192
+ private parseNameChain(tokens: readonly Token[]): Token[] {
193
+ const nameChain: Token[] = [];
194
+ let expect: "identifier" | "identifier or '.'" | "'.' or ']'" =
195
+ "identifier or '.'";
196
+ for (const token of tokens) {
197
+ let expected: boolean;
198
+ if (/^[a-zA-Z]/.test(token.text)) {
199
+ expected = expect === "identifier or '.'" || expect === "identifier";
200
+ expect = "'.' or ']'";
201
+ nameChain.push(token);
202
+ } else if (token.text === ".") {
203
+ expected = expect === "identifier or '.'" || expect === "'.' or ']'";
204
+ expect = "identifier";
205
+ } else {
206
+ assert(token.text === "]");
207
+ expected = expect === "'.' or ']'";
208
+ }
209
+ if (!expected) {
210
+ this.errors.push({
211
+ token: token,
212
+ expected: expect,
213
+ });
214
+ return [];
215
+ }
216
+ if (token.text === "]") {
217
+ return nameChain;
218
+ }
219
+ }
220
+ // An error has already been pushed to signify the unterminated reference.
221
+ return [];
222
+ }
223
+
224
+ /// The current doc comment being parsed.
225
+ private get docComment(): Token {
226
+ return this.docComments[this.docCommentIndex]!;
227
+ }
228
+
229
+ /// The text of the current doc comment being parsed.
230
+ private get content(): string {
231
+ return this.docComment.text;
232
+ }
233
+
234
+ private nextDocComment(): boolean {
235
+ if (this.docCommentIndex < this.docComments.length - 1) {
236
+ this.docCommentIndex++;
237
+ const { content } = this;
238
+ if (content.startsWith("/// ")) {
239
+ this.contentOffset = 4;
240
+ } else if (content.startsWith("///")) {
241
+ this.contentOffset = 3;
242
+ } else {
243
+ throw new Error("Expected doc comment to start with ///");
244
+ }
245
+ this.charIndex = this.contentOffset;
246
+ return true;
247
+ } else {
248
+ return false;
249
+ }
250
+ }
251
+ }