@hydranium/core 1.0.0-next.335 → 1.0.0-next.339

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.
@@ -24,7 +24,9 @@ import {
24
24
  type AstNodeLocator,
25
25
  AstUtils,
26
26
  DefaultScopeProvider,
27
+ type LocalSymbols,
27
28
  MapScope,
29
+ MultiMapScope,
28
30
  type ReferenceInfo,
29
31
  type Scope,
30
32
  type ScopeOptions,
@@ -104,16 +106,16 @@ export interface ScopeContext {
104
106
  * ## Scope chain (high → low, inner shadows outer)
105
107
  *
106
108
  * ```
107
- * document-local (Langium precomputed) [DefaultScopeProvider.getScope]
109
+ * document-local (Langium precomputed) [getScope, CACHED per build in localScope]
108
110
  * extension:local [layerLocalExtension, per-context]
109
111
  * own-project → dependency → universal-index [createGlobalScope default, CACHED per ${project}::${refType}]
110
112
  * extension:universal [layerUniversalExtension, per-context, outermost]
111
113
  * ```
112
114
  *
113
- * Implementation: Langium's `getScope` layers document-local on top of
114
- * whatever `getGlobalScope` returns. The framework owns `getGlobalScope`
115
- * and uses it to assemble everything *below* document-local, with the
116
- * adopter-overridable {@link createGlobalScope} in the middle.
115
+ * Implementation: {@link getScope} layers document-local on top of
116
+ * whatever `getGlobalScope` returns, as Langium's does. The framework owns
117
+ * `getGlobalScope` and uses it to assemble everything *below* document-local,
118
+ * with the adopter-overridable {@link createGlobalScope} in the middle.
117
119
  *
118
120
  * ## Adopter override surface
119
121
  *
@@ -162,6 +164,12 @@ export class HydraniumScopeProvider extends DefaultScopeProvider {
162
164
  * the workspace-global `DocumentBuilder.onUpdate`.
163
165
  */
164
166
  protected readonly scopeCache: WorkspaceCache<string, Scope>;
167
+ /**
168
+ * Each container's local scope per reference type, keyed by the local
169
+ * symbols it was built from. A build replaces a document's local symbols
170
+ * when it recomputes them, so a rebuilt document never reads a stale entry.
171
+ */
172
+ protected readonly localScopes = new WeakMap<LocalSymbols, Map<AstNode, Map<string, Scope>>>();
165
173
  protected readonly tracer: Tracer;
166
174
  /**
167
175
  * Narrows the inherited Langium {@link NameProvider} field to the framework's
@@ -204,14 +212,72 @@ export class HydraniumScopeProvider extends DefaultScopeProvider {
204
212
  this.globalScopeCache.clear();
205
213
  }
206
214
 
215
+ /**
216
+ * Langium's, with each container's local scope built once per build through
217
+ * {@link localScope} rather than once per reference. Linking a document then
218
+ * costs its references plus its declarations, not their product.
219
+ */
220
+ override getScope(context: ReferenceInfo): Scope {
221
+ const referenceType = this.reflection.getReferenceType(context);
222
+ const localSymbols = AstUtils.getDocument(context.container).localSymbols;
223
+ let result = this.getGlobalScope(referenceType, context);
224
+ if (localSymbols === undefined) {
225
+ return result;
226
+ }
227
+ const containers: AstNode[] = [];
228
+ for (let container: AstNode | undefined = context.container; container; container = container.$container) {
229
+ if (localSymbols.has(container)) {
230
+ containers.push(container);
231
+ }
232
+ }
233
+ // Outermost first, so the innermost container's symbols shadow the rest.
234
+ for (let i = containers.length - 1; i >= 0; i--) {
235
+ result = new FallbackScope(this.localScope(localSymbols, containers[i], referenceType), result);
236
+ }
237
+ return result;
238
+ }
239
+
240
+ /**
241
+ * The symbols `container` declares that a `referenceType` reference can
242
+ * reach, through {@link createScope}, built once per `localSymbols`.
243
+ */
244
+ protected localScope(localSymbols: LocalSymbols, container: AstNode, referenceType: string): Scope {
245
+ let byContainer = this.localScopes.get(localSymbols);
246
+ if (byContainer === undefined) {
247
+ byContainer = new Map();
248
+ this.localScopes.set(localSymbols, byContainer);
249
+ }
250
+ let byType = byContainer.get(container);
251
+ if (byType === undefined) {
252
+ byType = new Map();
253
+ byContainer.set(container, byType);
254
+ }
255
+ let scope = byType.get(referenceType);
256
+ if (scope === undefined) {
257
+ scope = this.createScope(
258
+ localSymbols.getStream(container).filter(description => this.reflection.isSubtype(description.type, referenceType))
259
+ );
260
+ byType.set(referenceType, scope);
261
+ }
262
+ return scope;
263
+ }
264
+
265
+ /**
266
+ * Keyed by name, where Langium's walks every element on each lookup. Every
267
+ * description of a name is kept and the first answers, as in Langium's;
268
+ * `getAllElements` lists them grouped by name.
269
+ */
270
+ protected override createScope(elements: Iterable<AstNodeDescription>, outerScope?: Scope, options?: ScopeOptions): Scope {
271
+ return new MultiMapScope(elements, outerScope, options);
272
+ }
273
+
207
274
  /**
208
275
  * Framework-owned assembly seam. **Adopters do NOT override this** —
209
276
  * override {@link createGlobalScope} instead. This method layers the
210
277
  * scope-extension tiers around the adopter-overridable global scope.
211
278
  *
212
- * Langium's `DefaultScopeProvider.getScope` then layers document-local
213
- * on top of this result, yielding the full chain documented on the
214
- * class.
279
+ * {@link getScope} then layers document-local on top of this result,
280
+ * yielding the full chain documented on the class.
215
281
  */
216
282
  protected override getGlobalScope(referenceType: string, context: ReferenceInfo): Scope {
217
283
  const projectChain = this.createGlobalScope(referenceType, context);
@@ -318,7 +384,7 @@ export class HydraniumScopeProvider extends DefaultScopeProvider {
318
384
  * closure).
319
385
  *
320
386
  * Contrast {@link getGlobalScope}, which applies the framework's
321
- * tier filter to this set, and `getScope` (Langium default), which
387
+ * tier filter to this set, and {@link getScope}, which
322
388
  * additionally merges in the local scope.
323
389
  */
324
390
  protected getAllElementsGlobalScope(referenceType: string, context: ReferenceInfo): Scope {
@@ -376,11 +442,11 @@ export class HydraniumScopeProvider extends DefaultScopeProvider {
376
442
  * Includes own-project `'public'`-tier descriptions when
377
443
  * {@link HydraniumScopeProviderOptions.includeOwnProjectPublic} is set
378
444
  * (routed to the own-project bucket by {@link bucketFor}). Returns a
379
- * {@link StreamScope} (not {@link MapScope}) so same-name siblings are
445
+ * {@link MultiMapScope} (not {@link MapScope}) so same-name siblings are
380
446
  * preserved rather than collapsed.
381
447
  */
382
448
  protected createOwnProjectScope(context: ScopeContext): Scope {
383
- return new StreamScope(stream(context.ownProjectEntries));
449
+ return new MultiMapScope(context.ownProjectEntries);
384
450
  }
385
451
 
386
452
  /**
@@ -389,7 +455,7 @@ export class HydraniumScopeProvider extends DefaultScopeProvider {
389
455
  * (own-project canonical filter applied by {@link bucketFor}).
390
456
  */
391
457
  protected createDependencyScope(context: ScopeContext): Scope {
392
- return new StreamScope(stream(context.dependencyEntries));
458
+ return new MultiMapScope(context.dependencyEntries);
393
459
  }
394
460
 
395
461
  /**
@@ -401,13 +467,13 @@ export class HydraniumScopeProvider extends DefaultScopeProvider {
401
467
  }
402
468
 
403
469
  /**
404
- * Chain three tier sub-scopes inner-first. The `outerScope` argument
405
- * of {@link StreamScope} is consulted only when the inner stream has
406
- * no match, so a more specific tier shadows a less specific one of
407
- * the same name.
470
+ * Chain three tier sub-scopes inner-first through {@link FallbackScope},
471
+ * which consults an outer tier only when the inner one has no match, so a
472
+ * more specific tier shadows a less specific one of the same name, and each
473
+ * tier keeps its own lookup.
408
474
  */
409
475
  protected chainScopes(ownScope: Scope, dependencyScope: Scope, universalScope: Scope): Scope {
410
- return new StreamScope(ownScope.getAllElements(), new StreamScope(dependencyScope.getAllElements(), universalScope));
476
+ return new FallbackScope(ownScope, new FallbackScope(dependencyScope, universalScope));
411
477
  }
412
478
 
413
479
  /**
@@ -143,6 +143,36 @@ export interface IndentationTokenNames {
143
143
  readonly dedent: string;
144
144
  }
145
145
 
146
+ /**
147
+ * A container a run of comment lines can be placed in: the run's own, or one
148
+ * it is lifted into when a block's DEDENT follows the run. `last` is the index
149
+ * in its content the run is placed after, `closed` the block the run is lifted
150
+ * out of, and `liftBelow` the column a comment line has to start left of to be
151
+ * lifted this far.
152
+ */
153
+ export interface CommentLiftTarget {
154
+ readonly container: CompositeCstNode;
155
+ readonly last: number;
156
+ readonly closed?: CompositeCstNode;
157
+ readonly liftBelow?: number;
158
+ }
159
+
160
+ /**
161
+ * A run of hidden nodes in `content`, from `start` to `end`, with the syntax
162
+ * before it and the containers its comments can be placed in, innermost first.
163
+ */
164
+ export interface CommentRun {
165
+ readonly content: readonly CstNode[];
166
+ readonly start: number;
167
+ readonly end: number;
168
+ readonly previous?: { readonly end: number };
169
+ readonly targets: readonly CommentLiftTarget[];
170
+ /** The nodes {@link CommentPreserver.neighbours} finds before the run, in order. */
171
+ readonly before: readonly CstNode[];
172
+ /** Per target, the nodes {@link CommentPreserver.neighbours} finds after its `last`. */
173
+ readonly after: readonly (readonly CstNode[])[];
174
+ }
175
+
146
176
  /**
147
177
  * One splice, with what it takes to check the risky kind afterwards.
148
178
  *
@@ -192,6 +222,18 @@ export class CommentPreserver implements TriviaPreserver<CommentTrivia> {
192
222
  protected readonly maxIsolatedEdits: number;
193
223
  protected commentTokenNames?: readonly string[];
194
224
  protected indentationTokenNames?: IndentationTokenNames | null;
225
+ /**
226
+ * Set for the duration of one {@link extract}: the run
227
+ * {@link resolveCommentRun} resolved last, so the run's next comment reuses
228
+ * it.
229
+ */
230
+ protected commentRun?: CommentRun;
231
+ /**
232
+ * Set for the duration of one {@link extract}: {@link syntaxSpan} per node,
233
+ * as a block a run is lifted out of would otherwise be measured once per
234
+ * comment.
235
+ */
236
+ protected spanMemo?: Map<CstNode, { readonly offset: number; readonly end: number } | undefined>;
195
237
  /** Set for the duration of one {@link apply}; see {@link cachedAnchorKey}. */
196
238
  protected keyMemo?: Map<AstNode, string | undefined>;
197
239
  /** Set for the duration of one {@link apply}; see {@link ambiguousRetainedKey}. */
@@ -259,9 +301,14 @@ export class CommentPreserver implements TriviaPreserver<CommentTrivia> {
259
301
  if (this.getIndentationTokenNames() === undefined) {
260
302
  return node;
261
303
  }
304
+ if (this.spanMemo?.has(node)) {
305
+ return this.spanMemo.get(node);
306
+ }
262
307
  const first = this.edgeToken(node, 1);
263
308
  const last = this.edgeToken(node, -1);
264
- return first && last && { offset: first.offset, end: last.end };
309
+ const span = first && last && { offset: first.offset, end: last.end };
310
+ this.spanMemo?.set(node, span);
311
+ return span;
265
312
  }
266
313
 
267
314
  /** The first token of `node` for a `step` of 1, the last for -1, that is neither hidden nor indentation. */
@@ -355,7 +402,14 @@ export class CommentPreserver implements TriviaPreserver<CommentTrivia> {
355
402
  }
356
403
  const source = document.textDocument.getText();
357
404
  const comments: DocumentComment[] = [];
358
- this.captureFrom(root, source, commentTokens, comments);
405
+ this.commentRun = undefined;
406
+ this.spanMemo = new Map();
407
+ try {
408
+ this.captureFrom(root, source, commentTokens, comments);
409
+ } finally {
410
+ this.commentRun = undefined;
411
+ this.spanMemo = undefined;
412
+ }
359
413
  return { comments, sourceRoot: document.parseResult.value };
360
414
  }
361
415
 
@@ -409,7 +463,14 @@ export class CommentPreserver implements TriviaPreserver<CommentTrivia> {
409
463
  const following = content[at + 1];
410
464
  const blankLinesAfter = following === undefined ? 0 : this.countBlankLines(source.slice(comment.end, offsetOf(following)));
411
465
  const preceding = at > 0 ? content[at - 1] : undefined;
412
- const blankLinesBefore = preceding === undefined ? 0 : this.countBlankLines(source.slice(endOf(preceding), comment.offset));
466
+ // From the last text before the comment: a block it is lifted out of can
467
+ // end with comment lines of its own, after the block's syntax.
468
+ const precedingEnd = preceding === undefined ? comment.offset : endOf(preceding);
469
+ let gapStart = comment.offset;
470
+ while (gapStart > precedingEnd && /\s/.test(source[gapStart - 1])) {
471
+ gapStart--;
472
+ }
473
+ const blankLinesBefore = preceding === undefined ? 0 : this.countBlankLines(source.slice(gapStart, comment.offset));
413
474
 
414
475
  // Only a comment that OWNS its line can be reindented — one sharing a line
415
476
  // with code keeps whatever spacing that line gives it.
@@ -418,7 +479,7 @@ export class CommentPreserver implements TriviaPreserver<CommentTrivia> {
418
479
  const sourceIndent = beforeOnLine.trim().length === 0 ? beforeOnLine : undefined;
419
480
 
420
481
  const text = this.normalizeLineEndings(comment.text);
421
- const sharesLineWithPrevious = previous !== undefined && !source.slice(endOf(previous), comment.offset).includes('\n');
482
+ const sharesLineWithPrevious = previous !== undefined && !this.breaksLine(source, endOf(previous), comment.offset);
422
483
 
423
484
  if (sharesLineWithPrevious) {
424
485
  // On the same line as whatever precedes it, so it annotates that line.
@@ -451,8 +512,10 @@ export class CommentPreserver implements TriviaPreserver<CommentTrivia> {
451
512
  }
452
513
 
453
514
  /**
454
- * Resolves the container {@link classify} anchors `comment` in and its
455
- * siblings there without INDENT and DEDENT tokens.
515
+ * Resolves the container {@link classify} anchors `comment` in, and the
516
+ * comment's adjacent siblings there and the nearest syntax on each side,
517
+ * without INDENT and DEDENT tokens. The content is that window, not all of
518
+ * the container's siblings.
456
519
  *
457
520
  * An indentation-aware lexer can emit a block's DEDENT after the comment
458
521
  * lines that follow the block, so the parser nests them in it. An own-line
@@ -466,10 +529,35 @@ export class CommentPreserver implements TriviaPreserver<CommentTrivia> {
466
529
  container: CompositeCstNode,
467
530
  source: string
468
531
  ): { readonly container: CompositeCstNode; readonly content: readonly CstNode[]; readonly index: number } {
469
- if (this.getIndentationTokenNames() === undefined) {
470
- return { container, content: container.content, index };
532
+ const content = container.content;
533
+ const run = this.resolveCommentRun(index, container, source);
534
+ let placed = 0;
535
+ if (run.targets.length > 1 && (run.previous === undefined || this.breaksLine(source, run.previous.end, comment.offset))) {
536
+ const column = this.columnAt(source, comment.offset);
537
+ while (placed + 1 < run.targets.length && column < (run.targets[placed + 1].liftBelow ?? 0)) {
538
+ placed++;
539
+ }
471
540
  }
541
+ const { container: parent, closed } = run.targets[placed];
542
+ const before = closed ? [closed] : run.before;
543
+ const around = content.slice(Math.max(index - 1, run.start), Math.min(index + 1, run.end) + 1);
544
+ const view = [...before, ...around, ...run.after[placed]];
545
+ return { container: parent, content: view, index: before.length + (index > run.start ? 1 : 0) };
546
+ }
547
+
548
+ /**
549
+ * The run of hidden nodes around `container.content[index]`, and the blocks
550
+ * it can be lifted out of: each that ends right after the run and whose
551
+ * INDENT opened a column, up to the first that does not.
552
+ */
553
+ protected resolveCommentRun(index: number, container: CompositeCstNode, source: string): CommentRun {
472
554
  const content = container.content;
555
+ const cached = this.commentRun;
556
+ // Resolved once per run, not per comment: a run of k comment lines would
557
+ // otherwise cost k scans of the run.
558
+ if (cached?.content === content && index >= cached.start && index <= cached.end) {
559
+ return cached;
560
+ }
473
561
  let start = index;
474
562
  while (start > 0 && content[start - 1].hidden) {
475
563
  start--;
@@ -478,37 +566,62 @@ export class CommentPreserver implements TriviaPreserver<CommentTrivia> {
478
566
  while (end + 1 < content.length && content[end + 1].hidden) {
479
567
  end++;
480
568
  }
481
- const run = content.slice(start, end + 1);
482
569
  let previous: { readonly end: number } | undefined;
483
- for (let scan = start - 1; scan >= 0 && previous === undefined; scan--) {
484
- previous = content[scan].hidden ? undefined : this.syntaxSpan(content[scan]);
570
+ const targets: CommentLiftTarget[] = [{ container, last: end }];
571
+ if (this.getIndentationTokenNames() !== undefined) {
572
+ for (let scan = start - 1; scan >= 0 && previous === undefined; scan--) {
573
+ previous = content[scan].hidden ? undefined : this.syntaxSpan(content[scan]);
574
+ }
575
+ let target = targets[0];
576
+ while (target.container.container && this.closesAfter(target.container.content, target.last)) {
577
+ const opened = this.openedColumn(target.container.content, target.closed ? target.last : start, source) ?? 0;
578
+ if (opened === 0) {
579
+ break;
580
+ }
581
+ const parent = target.container.container;
582
+ target = {
583
+ container: parent,
584
+ last: this.indexIn(parent.content, target.container),
585
+ closed: target.container,
586
+ liftBelow: opened
587
+ };
588
+ targets.push(target);
589
+ }
485
590
  }
486
- let parent = container;
487
- let closed: CstNode | undefined;
488
- if (previous === undefined || source.slice(previous.end, comment.offset).includes('\n')) {
489
- const column = this.columnAt(source, comment.offset);
490
- while (
491
- parent.container &&
492
- this.closesAfter(parent.content, closed ?? run[run.length - 1]) &&
493
- column < (this.openedColumn(parent.content, closed ?? run[0], source) ?? 0)
494
- ) {
495
- closed = parent;
496
- parent = parent.container;
591
+ const before = this.neighbours(content, start - 1, -1).reverse();
592
+ const after = targets.map(target => this.neighbours(target.container.content, target.last + 1, 1));
593
+ return (this.commentRun = { content, start, end, previous, targets, before, after });
594
+ }
595
+
596
+ /** The nodes of `content` from `from` in `step` direction up to the first that is syntax, without indentation tokens. */
597
+ protected neighbours(content: readonly CstNode[], from: number, step: 1 | -1): CstNode[] {
598
+ const found: CstNode[] = [];
599
+ for (let scan = from; scan >= 0 && scan < content.length; scan += step) {
600
+ const node = content[scan];
601
+ if (!this.isIndentation(node)) {
602
+ found.push(node);
603
+ if (!node.hidden) {
604
+ break;
605
+ }
497
606
  }
498
607
  }
499
- const view = parent.content.flatMap(node => (node === closed ? [node, ...run] : this.isIndentation(node) ? [] : [node]));
500
- return { container: parent, content: view, index: view.indexOf(comment) };
608
+ return found;
501
609
  }
502
610
 
503
- /** Whether only hidden and indentation tokens follow `node` in `content`. */
504
- protected closesAfter(content: readonly CstNode[], node: CstNode): boolean {
505
- return content.slice(content.indexOf(node) + 1).every(sibling => sibling.hidden || this.isIndentation(sibling));
611
+ /** Whether only hidden and indentation tokens follow `index` in `content`. */
612
+ protected closesAfter(content: readonly CstNode[], index: number): boolean {
613
+ for (let scan = index + 1; scan < content.length; scan++) {
614
+ if (!content[scan].hidden && !this.isIndentation(content[scan])) {
615
+ return false;
616
+ }
617
+ }
618
+ return true;
506
619
  }
507
620
 
508
- /** The column the last INDENT before `node` in `content` opens, `undefined` without one. */
509
- protected openedColumn(content: readonly CstNode[], node: CstNode, source: string): number | undefined {
621
+ /** The column the last INDENT before `content[index]` opens, `undefined` without one. */
622
+ protected openedColumn(content: readonly CstNode[], index: number, source: string): number | undefined {
510
623
  const indent = this.getIndentationTokenNames()?.indent;
511
- for (let scan = content.indexOf(node) - 1; scan >= 0; scan--) {
624
+ for (let scan = index - 1; scan >= 0; scan--) {
512
625
  if (tokenNameOf(content[scan]) === indent) {
513
626
  return this.columnAt(source, content[scan].end);
514
627
  }
@@ -516,6 +629,41 @@ export class CommentPreserver implements TriviaPreserver<CommentTrivia> {
516
629
  return undefined;
517
630
  }
518
631
 
632
+ /**
633
+ * The index of `node` in `content`, found by offset: `indexOf` per lifted
634
+ * block makes extraction quadratic in a block's siblings.
635
+ */
636
+ protected indexIn(content: readonly CstNode[], node: CstNode): number {
637
+ let low = 0;
638
+ let high = content.length;
639
+ while (low < high) {
640
+ const middle = (low + high) >> 1;
641
+ if (content[middle].offset < node.offset) {
642
+ low = middle + 1;
643
+ } else {
644
+ high = middle;
645
+ }
646
+ }
647
+ // Zero-width tokens can share the node's offset.
648
+ for (let index = low; index < content.length && content[index].offset === node.offset; index++) {
649
+ if (content[index] === node) {
650
+ return index;
651
+ }
652
+ }
653
+ return content.indexOf(node);
654
+ }
655
+
656
+ /** Whether `text` has a line break from `from` up to `to`, without copying the span. */
657
+ protected breaksLine(text: string, from: number, to: number): boolean {
658
+ // Not indexOf, which scans on to the next line break past `to`.
659
+ for (let offset = from; offset < to; offset++) {
660
+ if (text.charCodeAt(offset) === 10) {
661
+ return true;
662
+ }
663
+ }
664
+ return false;
665
+ }
666
+
519
667
  /** The column of `offset` in `text`. */
520
668
  protected columnAt(text: string, offset: number): number {
521
669
  return offset - (text.lastIndexOf('\n', offset - 1) + 1);
@@ -38,7 +38,7 @@ import {
38
38
  } from '@hydranium/langium';
39
39
  import type { CancellationToken } from 'vscode-languageserver-protocol';
40
40
  // A VALUE import, for `Diagnostic.getMessageString`: `message` is
41
- // `string | MarkupContent` in LSP 3.17+, so upstream's own reader is what
41
+ // `string | MarkupContent` since LSP 3.18, so upstream's own reader is what
42
42
  // narrows it rather than a hand-rolled union check here.
43
43
  import { Diagnostic } from 'vscode-languageserver-types';
44
44
  import { type LogNameOptions } from '../diagnostics/logger.js';
@@ -63,7 +63,7 @@ export function acceptMessage<S extends string, N extends AstNode, P extends Pro
63
63
  * Recover the identity from a published diagnostic, for a surface that
64
64
  * identifies or renders diagnostics itself.
65
65
  *
66
- * `Diagnostic.message` is `string | MarkupContent` in LSP 3.17+, so the text
66
+ * `Diagnostic.message` is `string | MarkupContent` since LSP 3.18, so the text
67
67
  * comes from upstream's own `Diagnostic.getMessageString` rather than a
68
68
  * hand-rolled narrowing — consuming the library in its style, and one fewer
69
69
  * place restating the union.