@nowline/lsp 0.8.5 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/providers/completion.d.ts +7 -1
  2. package/dist/providers/completion.d.ts.map +1 -1
  3. package/dist/providers/completion.js +42 -32
  4. package/dist/providers/completion.js.map +1 -1
  5. package/dist/providers/definition.d.ts +1 -1
  6. package/dist/providers/definition.d.ts.map +1 -1
  7. package/dist/providers/definition.js +1 -1
  8. package/dist/providers/document-symbols.d.ts +2 -1
  9. package/dist/providers/document-symbols.d.ts.map +1 -1
  10. package/dist/providers/document-symbols.js +25 -22
  11. package/dist/providers/document-symbols.js.map +1 -1
  12. package/dist/providers/hover.d.ts +9 -0
  13. package/dist/providers/hover.d.ts.map +1 -1
  14. package/dist/providers/hover.js +109 -14
  15. package/dist/providers/hover.js.map +1 -1
  16. package/dist/providers/references.d.ts.map +1 -1
  17. package/dist/providers/references.js +3 -1
  18. package/dist/providers/references.js.map +1 -1
  19. package/dist/providers/rename.d.ts.map +1 -1
  20. package/dist/providers/rename.js +2 -2
  21. package/dist/providers/rename.js.map +1 -1
  22. package/dist/references/ast-utils.d.ts +41 -31
  23. package/dist/references/ast-utils.d.ts.map +1 -1
  24. package/dist/references/ast-utils.js +54 -129
  25. package/dist/references/ast-utils.js.map +1 -1
  26. package/dist/server.js.map +1 -1
  27. package/package.json +10 -10
  28. package/src/providers/completion.ts +42 -31
  29. package/src/providers/definition.ts +1 -1
  30. package/src/providers/document-symbols.ts +31 -21
  31. package/src/providers/hover.ts +124 -14
  32. package/src/providers/references.ts +2 -0
  33. package/src/providers/rename.ts +2 -1
  34. package/src/references/ast-utils.ts +81 -127
@@ -18,7 +18,7 @@ import {
18
18
  * text) for cross-references, so Langium's default cross-ref-aware provider
19
19
  * has nothing to follow. We walk the AST instead: when the cursor sits on a
20
20
  * value of a reference property (`after:`, `before:`, `owner:`, `on:`,
21
- * `depends:[]`), look up the target id in the file-level entity index and
21
+ * `labels:[]`, ...), look up the target id in the file-level entity index and
22
22
  * return its declaration site.
23
23
  */
24
24
  export class NowlineDefinitionProvider implements DefinitionProvider {
@@ -13,11 +13,13 @@ import {
13
13
  isStatusDeclaration,
14
14
  isSwimlaneDeclaration,
15
15
  isTeamDeclaration,
16
+ isWaveDeclaration,
16
17
  type ParallelBlock,
17
18
  type RoadmapDeclaration,
18
- type SwimlaneContent,
19
19
  type SwimlaneDeclaration,
20
+ singleLine,
20
21
  } from '@nowline/core';
22
+ import { type TrackNode, trackChildren } from '@nowline/core/internal';
21
23
  import type { AstNode, LangiumDocument, MaybePromise } from 'langium';
22
24
  import type { DocumentSymbolProvider } from 'langium/lsp';
23
25
  import type {
@@ -30,10 +32,20 @@ import { SymbolKind } from '../lsp-protocol.js';
30
32
  import type { NowlineLspServices } from '../nowline-lsp-module.js';
31
33
  import { entityKind, fileFromDocument, nameRangeOf } from '../references/ast-utils.js';
32
34
 
35
+ /**
36
+ * An outline entry is one line of text, but a quoted title can carry
37
+ * explicit line breaks (`"Technology\nSelection"`), so each break is shown
38
+ * as a single space. `undefined` stays `undefined` so callers can fall back
39
+ * (as they must for a title that was only line breaks, which collapses to '').
40
+ */
41
+ function singleLineTitle(title: string | undefined): string | undefined {
42
+ return title === undefined ? undefined : singleLine(title);
43
+ }
44
+
33
45
  /**
34
46
  * Outline view: roadmap → swimlanes → items, with parallel/group nesting and
35
47
  * top-level anchors / milestones / footnotes / people / teams / labels /
36
- * sizes / statuses surfaced as siblings of the swimlanes. Mirrors the layout
48
+ * sizes / statuses / waves surfaced as siblings of the swimlanes. Mirrors the layout
37
49
  * engine's traversal so what authors see in the outline matches what gets
38
50
  * rendered.
39
51
  */
@@ -71,8 +83,8 @@ export class NowlineDocumentSymbolProvider implements DocumentSymbolProvider {
71
83
  const range = roadmap.$cstNode!.range;
72
84
  const nameRange = nameRangeOf(roadmap) ?? range;
73
85
  return {
74
- name: roadmap.name ?? roadmap.title ?? 'roadmap',
75
- detail: roadmap.title && roadmap.name ? roadmap.title : 'roadmap',
86
+ name: roadmap.name ?? (singleLineTitle(roadmap.title) || 'roadmap'),
87
+ detail: roadmap.title && roadmap.name ? singleLine(roadmap.title) : 'roadmap',
76
88
  kind: SymbolKind.Package,
77
89
  range,
78
90
  selectionRange: nameRange,
@@ -90,19 +102,15 @@ export class NowlineDocumentSymbolProvider implements DocumentSymbolProvider {
90
102
  if (isLabelDeclaration(entry)) return this.simpleSymbol(entry, SymbolKind.EnumMember);
91
103
  if (isSizeDeclaration(entry)) return this.simpleSymbol(entry, SymbolKind.Number);
92
104
  if (isStatusDeclaration(entry)) return this.simpleSymbol(entry, SymbolKind.Enum);
105
+ if (isWaveDeclaration(entry)) return this.simpleSymbol(entry, SymbolKind.Struct);
93
106
  return undefined;
94
107
  }
95
108
 
96
109
  private swimlaneSymbol(lane: SwimlaneDeclaration): DocumentSymbol {
97
- const sym = this.simpleSymbol(lane, SymbolKind.Namespace, true);
98
- for (const child of lane.content) {
99
- const sub = this.trackChildSymbol(child);
100
- if (sub) sym.children!.push(sub);
101
- }
102
- return sym;
110
+ return this.withTrackChildren(this.simpleSymbol(lane, SymbolKind.Namespace, true), lane);
103
111
  }
104
112
 
105
- private trackChildSymbol(child: SwimlaneContent): DocumentSymbol | undefined {
113
+ private trackChildSymbol(child: TrackNode): DocumentSymbol | undefined {
106
114
  if (isItemDeclaration(child)) return this.itemSymbol(child);
107
115
  if (isParallelBlock(child)) return this.parallelSymbol(child);
108
116
  if (isGroupBlock(child)) return this.groupSymbol(child);
@@ -114,17 +122,19 @@ export class NowlineDocumentSymbolProvider implements DocumentSymbolProvider {
114
122
  }
115
123
 
116
124
  private parallelSymbol(node: ParallelBlock): DocumentSymbol {
117
- const sym = this.simpleSymbol(node, SymbolKind.Array, true);
118
- for (const child of node.content) {
119
- const sub = this.trackChildSymbol(child);
120
- if (sub) sym.children!.push(sub);
121
- }
122
- return sym;
125
+ return this.withTrackChildren(this.simpleSymbol(node, SymbolKind.Array, true), node);
123
126
  }
124
127
 
125
128
  private groupSymbol(node: GroupBlock): DocumentSymbol {
126
- const sym = this.simpleSymbol(node, SymbolKind.Object, true);
127
- for (const child of node.content) {
129
+ return this.withTrackChildren(this.simpleSymbol(node, SymbolKind.Object, true), node);
130
+ }
131
+
132
+ // The container's track children (descriptions skipped) as nested symbols.
133
+ private withTrackChildren(
134
+ sym: DocumentSymbol,
135
+ container: SwimlaneDeclaration | GroupBlock | ParallelBlock,
136
+ ): DocumentSymbol {
137
+ for (const child of trackChildren(container)) {
128
138
  const sub = this.trackChildSymbol(child);
129
139
  if (sub) sym.children!.push(sub);
130
140
  }
@@ -137,8 +147,8 @@ export class NowlineDocumentSymbolProvider implements DocumentSymbolProvider {
137
147
  withChildrenSlot = false,
138
148
  ): DocumentSymbol {
139
149
  const range: Range = entity.$cstNode!.range;
140
- const name = entity.name ?? entity.title ?? entityKind(entity);
141
- const detail = entity.title && entity.name ? entity.title : entityKind(entity);
150
+ const name = entity.name ?? (singleLineTitle(entity.title) || entityKind(entity));
151
+ const detail = entity.title && entity.name ? singleLine(entity.title) : entityKind(entity);
142
152
  const symbol: DocumentSymbol = {
143
153
  name,
144
154
  detail,
@@ -1,4 +1,21 @@
1
- import type { LangiumDocument, MaybePromise } from 'langium';
1
+ import {
2
+ assignWaves,
3
+ isGroupBlock,
4
+ isItemDeclaration,
5
+ isNonWorkingDeclaration,
6
+ isParallelBlock,
7
+ isSwimlaneDeclaration,
8
+ isWaveDeclaration,
9
+ isWorkingDeclaration,
10
+ type NowlineFile,
11
+ ownWaves,
12
+ singleLine,
13
+ type WaveAssignment,
14
+ type WaveDeclaration,
15
+ waveIndexMap,
16
+ } from '@nowline/core';
17
+ import { classifyCalendarDeclaration } from '@nowline/core/internal';
18
+ import type { AstNode, LangiumDocument, MaybePromise } from 'langium';
2
19
  import type { HoverProvider } from 'langium/lsp';
3
20
  import type { CancellationToken, Hover, HoverParams, Range } from '../lsp-protocol.js';
4
21
  import { MarkupKind } from '../lsp-protocol.js';
@@ -8,6 +25,7 @@ import {
8
25
  declarationAt,
9
26
  entityKind,
10
27
  fileFromDocument,
28
+ isCalendarDeclaration,
11
29
  leafAt,
12
30
  type NamedEntity,
13
31
  propertyValueAt,
@@ -22,6 +40,15 @@ import {
22
40
  *
23
41
  * - The `name=ID` token of an entity declaration.
24
42
  * - The value of a reference property (`after:`, `before:`, `owner:`, etc.).
43
+ *
44
+ * Waves (specs/waves.md): a wave shows its position (`wave k of n`), title,
45
+ * description and member count; an item in a roadmap with waves shows its
46
+ * effective wave, or `background (no wave)`. Waves come from the file's own
47
+ * declarations only — the LSP does not resolve includes.
48
+ *
49
+ * Calendar declarations (`non-working`, `working`, specs/working-calendar.md):
50
+ * the declaration shows its id, title, and `date`, `every`, `start` and `end`,
51
+ * plus a note when its title is never drawn on the chart (§7.4).
25
52
  */
26
53
  export class NowlineHoverProvider implements HoverProvider {
27
54
  constructor(_services: NowlineLspServices) {
@@ -40,19 +67,19 @@ export class NowlineHoverProvider implements HoverProvider {
40
67
  if (!leaf) return undefined;
41
68
 
42
69
  const decl = declarationAt(leaf);
43
- if (decl) return this.hoverFor(decl, leaf.range);
70
+ if (decl) return this.hoverFor(file, decl, leaf.range);
44
71
 
45
72
  const propHit = propertyValueAt(leaf);
46
73
  if (propHit && REFERENCE_PROP_KEYS.has(propKey(propHit.prop))) {
47
74
  const target = buildEntityIndex(file).get(propHit.value);
48
75
  if (!target) return undefined;
49
- return this.hoverFor(target, leaf.range);
76
+ return this.hoverFor(file, target, leaf.range);
50
77
  }
51
78
 
52
79
  return undefined;
53
80
  }
54
81
 
55
- private hoverFor(entity: NamedEntity, range: Range): Hover {
82
+ private hoverFor(file: NowlineFile, entity: NamedEntity, range: Range): Hover {
56
83
  const kind = entityKind(entity);
57
84
  const id = entity.name ?? '';
58
85
  const title = entity.title;
@@ -61,20 +88,14 @@ export class NowlineHoverProvider implements HoverProvider {
61
88
  const header = id ? `**${kind} \`${id}\`**` : `**${kind}**`;
62
89
  lines.push(header);
63
90
  if (title) lines.push(`_${title}_`);
91
+ // Own paragraph so Markdown does not fold the wave lines into the title.
92
+ const waveInfo = waveLines(file, entity);
93
+ if (waveInfo.length) lines.push('', ...waveInfo);
64
94
 
65
95
  const props =
66
96
  (entity as { properties?: { key: string; value?: string; values?: string[] }[] })
67
97
  .properties ?? [];
68
- const surfaced = [
69
- 'status',
70
- 'owner',
71
- 'link',
72
- 'date',
73
- 'duration',
74
- 'size',
75
- 'effort',
76
- 'capacity',
77
- ];
98
+ const surfaced = isCalendarDeclaration(entity) ? CALENDAR_SURFACED : ITEM_SURFACED;
78
99
  const rendered: string[] = [];
79
100
  for (const key of surfaced) {
80
101
  const prop = props.find((p) => propKey(p as { key: string }) === key);
@@ -86,6 +107,8 @@ export class NowlineHoverProvider implements HoverProvider {
86
107
  lines.push('');
87
108
  lines.push(...rendered);
88
109
  }
110
+ const note = undrawnTitleNote(entity);
111
+ if (note) lines.push('', `_${note}_`);
89
112
 
90
113
  return {
91
114
  range,
@@ -96,3 +119,90 @@ export class NowlineHoverProvider implements HoverProvider {
96
119
  };
97
120
  }
98
121
  }
122
+
123
+ const ITEM_SURFACED = ['status', 'owner', 'link', 'date', 'duration', 'size', 'effort', 'capacity'];
124
+
125
+ // A `non-working` / `working` declaration is described by its days, not by an
126
+ // item's status or duration (specs/working-calendar.md §4.1).
127
+ const CALENDAR_SURFACED = ['date', 'every', 'start', 'end'];
128
+
129
+ /**
130
+ * Why a calendar declaration's title is never drawn, or undefined when it is
131
+ * drawn, there is no title, or the shape is malformed (NW1, §4.1; core's
132
+ * `classifyCalendarDeclaration` reads the shape). Dated,
133
+ * titled declarations are labelled, `non-working` or `working`; a titled
134
+ * bounded recurrence is not (specs/working-calendar.md §7.4 "Which rules are
135
+ * labelled"). The open-ended week is the baseline and gets no note.
136
+ */
137
+ function undrawnTitleNote(entity: NamedEntity): string | undefined {
138
+ if (!isNonWorkingDeclaration(entity) && !isWorkingDeclaration(entity)) return undefined;
139
+ if (!entity.title) return undefined;
140
+ const { shape } = classifyCalendarDeclaration(entity);
141
+ if (shape === 'half-bounded-recurrence' || shape === 'bounded-recurrence') {
142
+ return 'Title not drawn on the chart: bounded recurrences are not labelled.';
143
+ }
144
+ return undefined;
145
+ }
146
+
147
+ /**
148
+ * Wave lines for the hover: the wave summary on a wave declaration, the
149
+ * effective wave on an item. Empty when the file declares no waves.
150
+ */
151
+ function waveLines(file: NowlineFile, entity: NamedEntity): string[] {
152
+ if (!isWaveDeclaration(entity) && !isItemDeclaration(entity)) return [];
153
+ const waves = ownWaves(file);
154
+ if (waves.length === 0) return [];
155
+ const waveIds = waveIndexMap(file);
156
+ const lanes = file.roadmapEntries.filter(isSwimlaneDeclaration);
157
+ const assignment = assignWaves(lanes, waveIds);
158
+ if (isWaveDeclaration(entity)) return waveSummary(entity, waves, waveIds, assignment);
159
+ const ew = assignment.waveOf.get(entity);
160
+ if (ew === undefined) return ['background (no wave)'];
161
+ const wave = waves[ew - 1];
162
+ const source = waveSource(entity, assignment);
163
+ if (source === entity) return [`wave "${wave.name}"`];
164
+ return [`wave "${wave.name}" (inherited from ${describeContainer(source)})`];
165
+ }
166
+
167
+ function waveSummary(
168
+ wave: WaveDeclaration,
169
+ waves: readonly WaveDeclaration[],
170
+ waveIds: ReadonlyMap<string, number>,
171
+ assignment: WaveAssignment,
172
+ ): string[] {
173
+ const k = wave.name ? waveIds.get(wave.name) : undefined;
174
+ // A repeated wave id (NL.E0300) only counts its first declaration.
175
+ if (k === undefined || waves[k - 1] !== wave) return [];
176
+ const lines = [`wave ${k} of ${waves.length}`];
177
+ if (wave.description?.text) lines.push('', wave.description.text);
178
+ let members = 0;
179
+ for (const [node, ew] of assignment.waveOf) {
180
+ if (ew === k && isItemDeclaration(node)) members++;
181
+ }
182
+ lines.push('', `${members} ${members === 1 ? 'member' : 'members'}`);
183
+ const labels = wave.properties.find((p) => propKey(p) === 'labels');
184
+ const labelIds = labels?.values ?? (labels?.value ? [labels.value] : []);
185
+ if (labelIds.length) lines.push('', `labels: ${labelIds.join(', ')}`);
186
+ return lines;
187
+ }
188
+
189
+ // The node that sets an item's effective wave: the outermost of the item and
190
+ // its enclosing groups / parallels that has one. An enclosing container's wave
191
+ // wins over the item's own (specs/waves.md §4.4), and it reaches the item only
192
+ // through the containers in between.
193
+ function waveSource(item: AstNode, assignment: WaveAssignment): AstNode {
194
+ let source = item;
195
+ let node = item.$container;
196
+ while ((isGroupBlock(node) || isParallelBlock(node)) && assignment.waveOf.has(node)) {
197
+ source = node;
198
+ node = node.$container;
199
+ }
200
+ return source;
201
+ }
202
+
203
+ function describeContainer(node: AstNode): string {
204
+ const kind = entityKind(node);
205
+ const named = node as { name?: string; title?: string };
206
+ const label = named.name ?? (named.title === undefined ? undefined : singleLine(named.title));
207
+ return label ? `${kind} "${label}"` : kind;
208
+ }
@@ -8,6 +8,7 @@ import {
8
8
  declarationAt,
9
9
  fileFromDocument,
10
10
  findDeclarationRange,
11
+ isCalendarDeclaration,
11
12
  leafAt,
12
13
  propertyValueAt,
13
14
  propKey,
@@ -66,6 +67,7 @@ export class NowlineReferencesProvider implements ReferencesProvider {
66
67
 
67
68
  function resolveTargetId(leaf: CstNode): string | undefined {
68
69
  const decl = declarationAt(leaf);
70
+ if (decl && isCalendarDeclaration(decl)) return undefined;
69
71
  if (decl?.name) return decl.name;
70
72
  const propHit = propertyValueAt(leaf);
71
73
  if (propHit && REFERENCE_PROP_KEYS.has(propKey(propHit.prop))) {
@@ -16,6 +16,7 @@ import {
16
16
  declarationAt,
17
17
  fileFromDocument,
18
18
  findDeclarationRange,
19
+ isCalendarDeclaration,
19
20
  leafAt,
20
21
  propertyValueAt,
21
22
  propKey,
@@ -93,7 +94,7 @@ function renameRangeAt(document: LangiumDocument, position: Position): RenameTar
93
94
  const leaf = leafAt(document, offset);
94
95
  if (!leaf) return undefined;
95
96
  const decl = declarationAt(leaf);
96
- if (decl?.name === leaf.text) {
97
+ if (decl && !isCalendarDeclaration(decl) && decl.name === leaf.text) {
97
98
  return { id: decl.name, range: leaf.range };
98
99
  }
99
100
  const hit = propertyValueAt(leaf);