@nowline/lsp 0.4.1

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 (53) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +49 -0
  3. package/dist/index.d.ts +9 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +9 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/nowline-lsp-module.d.ts +29 -0
  8. package/dist/nowline-lsp-module.d.ts.map +1 -0
  9. package/dist/nowline-lsp-module.js +49 -0
  10. package/dist/nowline-lsp-module.js.map +1 -0
  11. package/dist/providers/completion.d.ts +28 -0
  12. package/dist/providers/completion.d.ts.map +1 -0
  13. package/dist/providers/completion.js +175 -0
  14. package/dist/providers/completion.js.map +1 -0
  15. package/dist/providers/definition.d.ts +18 -0
  16. package/dist/providers/definition.d.ts.map +1 -0
  17. package/dist/providers/definition.js +39 -0
  18. package/dist/providers/definition.js.map +1 -0
  19. package/dist/providers/document-symbols.d.ts +24 -0
  20. package/dist/providers/document-symbols.d.ts.map +1 -0
  21. package/dist/providers/document-symbols.js +126 -0
  22. package/dist/providers/document-symbols.js.map +1 -0
  23. package/dist/providers/hover.d.ts +18 -0
  24. package/dist/providers/hover.d.ts.map +1 -0
  25. package/dist/providers/hover.js +78 -0
  26. package/dist/providers/hover.js.map +1 -0
  27. package/dist/providers/references.d.ts +19 -0
  28. package/dist/providers/references.d.ts.map +1 -0
  29. package/dist/providers/references.js +70 -0
  30. package/dist/providers/references.js.map +1 -0
  31. package/dist/providers/rename.d.ts +16 -0
  32. package/dist/providers/rename.d.ts.map +1 -0
  33. package/dist/providers/rename.js +72 -0
  34. package/dist/providers/rename.js.map +1 -0
  35. package/dist/references/ast-utils.d.ts +126 -0
  36. package/dist/references/ast-utils.d.ts.map +1 -0
  37. package/dist/references/ast-utils.js +313 -0
  38. package/dist/references/ast-utils.js.map +1 -0
  39. package/dist/server.d.ts +9 -0
  40. package/dist/server.d.ts.map +1 -0
  41. package/dist/server.js +35 -0
  42. package/dist/server.js.map +1 -0
  43. package/package.json +47 -0
  44. package/src/index.ts +26 -0
  45. package/src/nowline-lsp-module.ts +102 -0
  46. package/src/providers/completion.ts +202 -0
  47. package/src/providers/definition.ts +58 -0
  48. package/src/providers/document-symbols.ts +152 -0
  49. package/src/providers/hover.ts +98 -0
  50. package/src/providers/references.ts +86 -0
  51. package/src/providers/rename.ts +109 -0
  52. package/src/references/ast-utils.ts +378 -0
  53. package/src/server.ts +33 -0
@@ -0,0 +1,109 @@
1
+ import type { LangiumDocument, MaybePromise } from 'langium';
2
+ import { CstUtils } from 'langium';
3
+ import type { RenameProvider } from 'langium/lsp';
4
+ import type {
5
+ CancellationToken,
6
+ Position,
7
+ PrepareRenameParams,
8
+ Range,
9
+ RenameParams,
10
+ TextDocumentPositionParams,
11
+ TextEdit,
12
+ WorkspaceEdit,
13
+ } from 'vscode-languageserver';
14
+ import type { NowlineLspServices } from '../nowline-lsp-module.js';
15
+ import {
16
+ declarationAt,
17
+ fileFromDocument,
18
+ findDeclarationRange,
19
+ leafAt,
20
+ propertyValueAt,
21
+ propKey,
22
+ REFERENCE_PROP_KEYS,
23
+ visitAllProperties,
24
+ } from '../references/ast-utils.js';
25
+
26
+ /**
27
+ * Rename an entity id (item, swimlane, anchor, etc.) and propagate the change
28
+ * to every reference position in the file. Refuses to rename when the cursor
29
+ * isn't on a renameable token, when the new name collides with an existing
30
+ * declaration, or when the new name isn't a valid Nowline identifier.
31
+ */
32
+ export class NowlineRenameProvider implements RenameProvider {
33
+ constructor(_services: NowlineLspServices) {
34
+ /* AST helpers are pure. */
35
+ }
36
+
37
+ prepareRename(
38
+ document: LangiumDocument,
39
+ params: TextDocumentPositionParams | PrepareRenameParams,
40
+ _cancelToken?: CancellationToken,
41
+ ): MaybePromise<Range | undefined> {
42
+ return renameRangeAt(document, params.position)?.range;
43
+ }
44
+
45
+ async rename(
46
+ document: LangiumDocument,
47
+ params: RenameParams,
48
+ _cancelToken?: CancellationToken,
49
+ ): Promise<WorkspaceEdit | undefined> {
50
+ const file = fileFromDocument(document);
51
+ if (!file) return undefined;
52
+ const target = renameRangeAt(document, params.position);
53
+ if (!target) return undefined;
54
+
55
+ const newName = params.newName;
56
+ if (!isValidId(newName)) {
57
+ throw new Error(
58
+ `"${newName}" is not a valid Nowline identifier. Use letters, digits, underscores, and dashes (must start with a letter or underscore).`,
59
+ );
60
+ }
61
+
62
+ const edits: TextEdit[] = [];
63
+ const uri = document.uri.toString();
64
+
65
+ const declRange = findDeclarationRange(file, target.id);
66
+ if (declRange) edits.push({ range: declRange, newText: newName });
67
+
68
+ visitAllProperties(file, (prop) => {
69
+ if (!REFERENCE_PROP_KEYS.has(propKey(prop))) return;
70
+ const cst = prop.$cstNode;
71
+ if (!cst) return;
72
+ for (const leaf of CstUtils.flattenCst(cst)) {
73
+ if (leaf.hidden) continue;
74
+ if (leaf.text.endsWith(':')) continue;
75
+ if (leaf.text === target.id) {
76
+ edits.push({ range: leaf.range, newText: newName });
77
+ }
78
+ }
79
+ });
80
+
81
+ if (edits.length === 0) return undefined;
82
+ return { changes: { [uri]: edits } };
83
+ }
84
+ }
85
+
86
+ interface RenameTarget {
87
+ id: string;
88
+ range: Range;
89
+ }
90
+
91
+ function renameRangeAt(document: LangiumDocument, position: Position): RenameTarget | undefined {
92
+ const offset = document.textDocument.offsetAt(position);
93
+ const leaf = leafAt(document, offset);
94
+ if (!leaf) return undefined;
95
+ const decl = declarationAt(leaf);
96
+ if (decl?.name === leaf.text) {
97
+ return { id: decl.name, range: leaf.range };
98
+ }
99
+ const hit = propertyValueAt(leaf);
100
+ if (hit && REFERENCE_PROP_KEYS.has(propKey(hit.prop))) {
101
+ return { id: hit.value, range: hit.valueNode.range };
102
+ }
103
+ return undefined;
104
+ }
105
+
106
+ const ID_RE = /^[a-zA-Z_][a-zA-Z0-9_-]*$/;
107
+ function isValidId(value: string): boolean {
108
+ return ID_RE.test(value);
109
+ }
@@ -0,0 +1,378 @@
1
+ import type {
2
+ AnchorDeclaration,
3
+ EntityProperty,
4
+ FootnoteDeclaration,
5
+ GroupBlock,
6
+ GroupContent,
7
+ ItemDeclaration,
8
+ LabelDeclaration,
9
+ MilestoneDeclaration,
10
+ NowlineFile,
11
+ ParallelBlock,
12
+ ParallelContent,
13
+ PersonDeclaration,
14
+ SizeDeclaration,
15
+ StatusDeclaration,
16
+ StyleDeclaration,
17
+ SwimlaneContent,
18
+ SwimlaneDeclaration,
19
+ SymbolDeclaration,
20
+ TeamDeclaration,
21
+ } from '@nowline/core';
22
+ import {
23
+ isAnchorDeclaration,
24
+ isFootnoteDeclaration,
25
+ isGroupBlock,
26
+ isItemDeclaration,
27
+ isLabelDeclaration,
28
+ isMilestoneDeclaration,
29
+ isNowlineFile,
30
+ isParallelBlock,
31
+ isPersonDeclaration,
32
+ isPersonMemberRef,
33
+ isSizeDeclaration,
34
+ isStatusDeclaration,
35
+ isStyleDeclaration,
36
+ isSwimlaneDeclaration,
37
+ isSymbolDeclaration,
38
+ isTeamDeclaration,
39
+ } from '@nowline/core';
40
+ import type { AstNode, CstNode, LangiumDocument } from 'langium';
41
+ import { CstUtils } from 'langium';
42
+ import type { Range } from 'vscode-languageserver';
43
+
44
+ /**
45
+ * Property keys whose value(s) name another entity in the same file. Used by
46
+ * definition / references / rename / completion to decide whether the cursor
47
+ * sits on an id reference.
48
+ *
49
+ * Wider than the validator's `checkReferenceResolution` (which is strict only
50
+ * for sequencing — `after` / `before` / `on`). Categorisation references
51
+ * (`size` / `status` / `style` / `labels`), decoration references
52
+ * (`icon` / `capacity-icon`), and ownership references (`owner` / `team`) are
53
+ * permissive in the validator (an undeclared id is not an error) but still
54
+ * navigable when a declaration exists.
55
+ *
56
+ * `footnote:` is intentionally absent — the legacy `item … footnote:foo`
57
+ * form was removed in favor of the spec-mandated reverse direction.
58
+ * Footnote association flows only from the footnote declaration's `on:`,
59
+ * and the validator now rejects `footnote:` as a property on host entities.
60
+ */
61
+ export const REFERENCE_PROP_KEYS = new Set([
62
+ 'after',
63
+ 'before',
64
+ 'depends',
65
+ 'on',
66
+ 'owner',
67
+ 'team',
68
+ 'size',
69
+ 'status',
70
+ 'style',
71
+ 'labels',
72
+ 'icon',
73
+ 'capacity-icon',
74
+ ]);
75
+
76
+ /** Status value-completion source: built-in statuses ship with the renderer. */
77
+ export const BUILTIN_STATUSES: readonly string[] = [
78
+ 'planned',
79
+ 'in-progress',
80
+ 'done',
81
+ 'at-risk',
82
+ 'blocked',
83
+ ];
84
+
85
+ /**
86
+ * Built-in `capacity-icon:` vocabulary. Mirrors `BUILTIN_CAPACITY_ICONS` in
87
+ * the validator — kept as a literal here so the LSP doesn't have to import a
88
+ * non-public symbol from `@nowline/core`.
89
+ */
90
+ export const BUILTIN_CAPACITY_ICONS: readonly string[] = [
91
+ 'none',
92
+ 'multiplier',
93
+ 'person',
94
+ 'people',
95
+ 'points',
96
+ 'time',
97
+ ];
98
+
99
+ /**
100
+ * Built-in `icon:` vocabulary. Superset of `BUILTIN_CAPACITY_ICONS` plus the
101
+ * entity-decoration glyphs the renderer ships.
102
+ */
103
+ export const BUILTIN_ICON_NAMES: readonly string[] = [
104
+ ...BUILTIN_CAPACITY_ICONS,
105
+ 'shield',
106
+ 'warning',
107
+ 'lock',
108
+ ];
109
+
110
+ /** Strip the trailing `:` an `EntityProperty.key` carries from the grammar. */
111
+ export function propKey(prop: { key: string }): string {
112
+ return prop.key.endsWith(':') ? prop.key.slice(0, -1) : prop.key;
113
+ }
114
+
115
+ /**
116
+ * Named declaration shapes we expose for navigation. All have `name?: string`,
117
+ * `title?: string`, and `properties?: EntityProperty[]`.
118
+ *
119
+ * Includes both roadmap-section declarations (item, swimlane, person, …) and
120
+ * config-section declarations (`style`, `symbol`) so cmd+click on
121
+ * `style:enterprise-style` and `icon:my-symbol` resolves to the right place.
122
+ */
123
+ export type NamedEntity =
124
+ | RoadmapEntryNamed
125
+ | ItemDeclaration
126
+ | ParallelBlock
127
+ | GroupBlock
128
+ | StyleDeclaration
129
+ | SymbolDeclaration;
130
+
131
+ type RoadmapEntryNamed =
132
+ | SwimlaneDeclaration
133
+ | AnchorDeclaration
134
+ | MilestoneDeclaration
135
+ | FootnoteDeclaration
136
+ | PersonDeclaration
137
+ | TeamDeclaration
138
+ | LabelDeclaration
139
+ | SizeDeclaration
140
+ | StatusDeclaration;
141
+
142
+ /**
143
+ * Walk the `NowlineFile` and collect every entity that owns an `id`. Covers
144
+ * roadmap-section entities (mirrors the validator's `collectReferenceableIds`)
145
+ * plus the named config-section declarations (`style`, `symbol`) so style and
146
+ * symbol references navigate correctly. Anonymous config blocks (`scale`,
147
+ * `calendar`, `default <entity>`) are skipped.
148
+ */
149
+ export function collectNamedEntities(file: NowlineFile): NamedEntity[] {
150
+ const out: NamedEntity[] = [];
151
+
152
+ for (const entry of file.configEntries) {
153
+ if (isStyleDeclaration(entry) && entry.name) out.push(entry);
154
+ else if (isSymbolDeclaration(entry) && entry.name) out.push(entry);
155
+ }
156
+
157
+ const visitTeam = (team: TeamDeclaration) => {
158
+ if (team.name) out.push(team);
159
+ for (const c of team.content) {
160
+ if (isTeamDeclaration(c)) visitTeam(c);
161
+ // `PersonMemberRef` and `DescriptionDirective` are references / prose,
162
+ // not declarations — skip.
163
+ else if (isPersonMemberRef(c)) {
164
+ /* skip */
165
+ }
166
+ }
167
+ };
168
+
169
+ const visitTrackChild = (child: SwimlaneContent | GroupContent | ParallelContent) => {
170
+ if (isItemDeclaration(child) && child.name) out.push(child);
171
+ else if (isParallelBlock(child)) {
172
+ if (child.name) out.push(child);
173
+ for (const sub of child.content) visitTrackChild(sub);
174
+ } else if (isGroupBlock(child)) {
175
+ if (child.name) out.push(child);
176
+ for (const sub of child.content) visitTrackChild(sub);
177
+ }
178
+ };
179
+
180
+ for (const entry of file.roadmapEntries) {
181
+ if (isSwimlaneDeclaration(entry)) {
182
+ if (entry.name) out.push(entry);
183
+ for (const c of entry.content) visitTrackChild(c);
184
+ } else if (isTeamDeclaration(entry)) visitTeam(entry);
185
+ else if (
186
+ isPersonDeclaration(entry) ||
187
+ isAnchorDeclaration(entry) ||
188
+ isMilestoneDeclaration(entry) ||
189
+ isFootnoteDeclaration(entry) ||
190
+ isLabelDeclaration(entry) ||
191
+ isSizeDeclaration(entry) ||
192
+ isStatusDeclaration(entry)
193
+ ) {
194
+ if (entry.name) out.push(entry);
195
+ }
196
+ }
197
+
198
+ return out;
199
+ }
200
+
201
+ /**
202
+ * Build a map from id → declaring entity. When a name appears multiple times
203
+ * the validator already flags it; we keep the first occurrence so navigation
204
+ * is deterministic.
205
+ */
206
+ export function buildEntityIndex(file: NowlineFile): Map<string, NamedEntity> {
207
+ const index = new Map<string, NamedEntity>();
208
+ for (const ent of collectNamedEntities(file)) {
209
+ const id = ent.name;
210
+ if (id && !index.has(id)) index.set(id, ent);
211
+ }
212
+ return index;
213
+ }
214
+
215
+ /** Visit every `EntityProperty` reachable from a roadmap entry, depth-first. */
216
+ export function visitProperties(
217
+ node: AstNode,
218
+ visit: (prop: EntityProperty, owner: AstNode) => void,
219
+ ): void {
220
+ const walk = (n: AstNode): void => {
221
+ const props = (n as { properties?: EntityProperty[] }).properties;
222
+ if (Array.isArray(props)) {
223
+ for (const p of props) visit(p, n);
224
+ }
225
+ if (isSwimlaneDeclaration(n)) {
226
+ for (const c of n.content) walk(c);
227
+ } else if (isParallelBlock(n) || isGroupBlock(n)) {
228
+ for (const c of n.content) walk(c);
229
+ } else if (isTeamDeclaration(n)) {
230
+ for (const c of n.content) {
231
+ if (isTeamDeclaration(c) || isPersonDeclaration(c)) walk(c);
232
+ }
233
+ }
234
+ };
235
+ walk(node);
236
+ }
237
+
238
+ /**
239
+ * Iterate every `EntityProperty` in the file, including those on the roadmap
240
+ * declaration itself, plus the properties on top-level config entries (style /
241
+ * default / symbol blocks). Useful for find-references and completion when the
242
+ * cursor isn't necessarily inside a roadmap entry.
243
+ */
244
+ export function visitAllProperties(
245
+ file: NowlineFile,
246
+ visit: (prop: EntityProperty, owner: AstNode) => void,
247
+ ): void {
248
+ if (file.roadmapDecl) {
249
+ for (const p of file.roadmapDecl.properties) visit(p, file.roadmapDecl);
250
+ }
251
+ for (const entry of file.roadmapEntries) visitProperties(entry, visit);
252
+ }
253
+
254
+ /**
255
+ * Pull the `NowlineFile` AST root from a Langium document.
256
+ */
257
+ export function fileFromDocument(document: LangiumDocument): NowlineFile | undefined {
258
+ const root = document.parseResult?.value as AstNode | undefined;
259
+ return root && isNowlineFile(root) ? (root as NowlineFile) : undefined;
260
+ }
261
+
262
+ /**
263
+ * Find the leaf CST node directly under the given offset. Wraps
264
+ * `CstUtils.findLeafNodeAtOffset` to make the call sites read clearly.
265
+ */
266
+ export function leafAt(document: LangiumDocument, offset: number): CstNode | undefined {
267
+ const root = document.parseResult?.value?.$cstNode;
268
+ if (!root) return undefined;
269
+ return CstUtils.findLeafNodeAtOffset(root, offset);
270
+ }
271
+
272
+ /**
273
+ * When `leaf` sits inside the value(s) of an `EntityProperty`, return the
274
+ * containing property along with the matched value text. Returns `undefined`
275
+ * when the leaf is the property key, surrounding punctuation, or otherwise
276
+ * outside a property value.
277
+ *
278
+ * Used by definition / hover / references to detect "cursor on a reference".
279
+ */
280
+ export function propertyValueAt(
281
+ leaf: CstNode | undefined,
282
+ ): { prop: EntityProperty; value: string; valueNode: CstNode } | undefined {
283
+ if (!leaf) return undefined;
284
+ let node: CstNode | undefined = leaf;
285
+ while (node && node.astNode.$type !== 'EntityProperty') node = node.container;
286
+ if (!node) return undefined;
287
+ const prop = node.astNode as EntityProperty;
288
+ const text = leaf.text;
289
+ if (prop.value === text) {
290
+ return { prop, value: prop.value, valueNode: leaf };
291
+ }
292
+ if (prop.values?.includes(text)) {
293
+ return { prop, value: text, valueNode: leaf };
294
+ }
295
+ return undefined;
296
+ }
297
+
298
+ /**
299
+ * When `leaf` is the `name=ID` token of a named entity declaration, return the
300
+ * declaring entity. Returns `undefined` for any other position.
301
+ */
302
+ export function declarationAt(leaf: CstNode | undefined): NamedEntity | undefined {
303
+ if (!leaf) return undefined;
304
+ const owner = leaf.astNode as AstNode & { name?: string };
305
+ if (!owner || typeof owner.name !== 'string') return undefined;
306
+ if (owner.name !== leaf.text) return undefined;
307
+ if (
308
+ isSwimlaneDeclaration(owner) ||
309
+ isItemDeclaration(owner) ||
310
+ isParallelBlock(owner) ||
311
+ isGroupBlock(owner) ||
312
+ isAnchorDeclaration(owner) ||
313
+ isMilestoneDeclaration(owner) ||
314
+ isFootnoteDeclaration(owner) ||
315
+ isPersonDeclaration(owner) ||
316
+ isTeamDeclaration(owner) ||
317
+ isLabelDeclaration(owner) ||
318
+ isSizeDeclaration(owner) ||
319
+ isStatusDeclaration(owner) ||
320
+ isStyleDeclaration(owner) ||
321
+ isSymbolDeclaration(owner)
322
+ ) {
323
+ return owner as NamedEntity;
324
+ }
325
+ return undefined;
326
+ }
327
+
328
+ /**
329
+ * Look up the `name=ID` token range of the entity that declares `id`. Walks
330
+ * `collectNamedEntities` so config-section declarations (style / symbol)
331
+ * participate. Returns `undefined` when no declaration matches.
332
+ *
333
+ * Replaces the per-provider `findDeclarationRange` helpers in
334
+ * references.ts and rename.ts so all providers agree on what "declared"
335
+ * means.
336
+ */
337
+ export function findDeclarationRange(file: NowlineFile, id: string): Range | undefined {
338
+ for (const ent of collectNamedEntities(file)) {
339
+ if (ent.name === id) {
340
+ const range = nameRangeOf(ent);
341
+ if (range) return range;
342
+ }
343
+ }
344
+ return undefined;
345
+ }
346
+
347
+ /**
348
+ * Render a one-line "type id" label used by hover and document-symbols.
349
+ */
350
+ export function entityKind(entity: AstNode): string {
351
+ return entity.$type
352
+ .replace(/Declaration$/, '')
353
+ .replace(/Block$/, '')
354
+ .toLowerCase();
355
+ }
356
+
357
+ /**
358
+ * Find the CST node that holds the given entity's `name=ID` token. Walks the
359
+ * entity's CST subtree and returns the first leaf whose text equals the
360
+ * entity's resolved name. Returns `undefined` when the entity has no `name`
361
+ * (anonymous declaration), no CST node, or no matching leaf.
362
+ */
363
+ export function nameRangeOf(entity: AstNode): Range | undefined {
364
+ const name = (entity as { name?: string }).name;
365
+ if (!name || !entity.$cstNode) return undefined;
366
+ for (const leaf of CstUtils.flattenCst(entity.$cstNode)) {
367
+ if (leaf.text === name) return leaf.range;
368
+ }
369
+ return undefined;
370
+ }
371
+
372
+ /**
373
+ * Iterate every leaf CST node inside the given root. Convenience wrapper over
374
+ * `CstUtils.flattenCst` so call sites don't need to import the namespace.
375
+ */
376
+ export function* flattenLeaves(root: CstNode): IterableIterator<CstNode> {
377
+ for (const leaf of CstUtils.flattenCst(root)) yield leaf;
378
+ }
package/src/server.ts ADDED
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ import { startLanguageServer } from 'langium/lsp';
3
+ import { NodeFileSystem } from 'langium/node';
4
+ import { createConnection, ProposedFeatures } from 'vscode-languageserver/node.js';
5
+ import { createNowlineLspServices } from './nowline-lsp-module.js';
6
+
7
+ /**
8
+ * Boot the Nowline language server over stdio. Used both by the bundled VS
9
+ * Code / Cursor extension (spawned as a Node module via `LanguageClient`) and
10
+ * by the standalone `nowline-lsp` binary editors like Neovim and JetBrains
11
+ * point at via the LSP config.
12
+ */
13
+ export function startNowlineServer(): void {
14
+ const connection = createConnection(ProposedFeatures.all);
15
+ const { shared } = createNowlineLspServices({
16
+ connection,
17
+ fileSystemProvider: NodeFileSystem.fileSystemProvider,
18
+ });
19
+ startLanguageServer(shared);
20
+ }
21
+
22
+ // Only auto-start when invoked directly (the binary entrypoint). Importers
23
+ // (e.g. tests or in-process embeddings) call `startNowlineServer()` themselves.
24
+ const isDirect = (() => {
25
+ if (typeof process === 'undefined' || !process.argv?.[1]) return false;
26
+ try {
27
+ const url = new URL(import.meta.url);
28
+ return url.protocol === 'file:' && url.pathname === process.argv[1];
29
+ } catch {
30
+ return false;
31
+ }
32
+ })();
33
+ if (isDirect) startNowlineServer();