@nowline/cli 0.0.0-dev.20260601071750.g04bdff9

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 (139) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +372 -0
  3. package/dist/cli/args.d.ts +54 -0
  4. package/dist/cli/args.d.ts.map +1 -0
  5. package/dist/cli/args.js +165 -0
  6. package/dist/cli/args.js.map +1 -0
  7. package/dist/cli/formats.d.ts +61 -0
  8. package/dist/cli/formats.d.ts.map +1 -0
  9. package/dist/cli/formats.js +153 -0
  10. package/dist/cli/formats.js.map +1 -0
  11. package/dist/cli/help.d.ts +3 -0
  12. package/dist/cli/help.d.ts.map +1 -0
  13. package/dist/cli/help.js +90 -0
  14. package/dist/cli/help.js.map +1 -0
  15. package/dist/cli/output-path.d.ts +57 -0
  16. package/dist/cli/output-path.d.ts.map +1 -0
  17. package/dist/cli/output-path.js +70 -0
  18. package/dist/cli/output-path.js.map +1 -0
  19. package/dist/commands/init.d.ts +20 -0
  20. package/dist/commands/init.d.ts.map +1 -0
  21. package/dist/commands/init.js +80 -0
  22. package/dist/commands/init.js.map +1 -0
  23. package/dist/commands/render.d.ts +15 -0
  24. package/dist/commands/render.d.ts.map +1 -0
  25. package/dist/commands/render.js +436 -0
  26. package/dist/commands/render.js.map +1 -0
  27. package/dist/commands/serve.d.ts +16 -0
  28. package/dist/commands/serve.d.ts.map +1 -0
  29. package/dist/commands/serve.js +287 -0
  30. package/dist/commands/serve.js.map +1 -0
  31. package/dist/convert/parse-json.d.ts +7 -0
  32. package/dist/convert/parse-json.d.ts.map +1 -0
  33. package/dist/convert/parse-json.js +34 -0
  34. package/dist/convert/parse-json.js.map +1 -0
  35. package/dist/convert/printer.d.ts +6 -0
  36. package/dist/convert/printer.d.ts.map +1 -0
  37. package/dist/convert/printer.js +334 -0
  38. package/dist/convert/printer.js.map +1 -0
  39. package/dist/convert/schema.d.ts +33 -0
  40. package/dist/convert/schema.d.ts.map +1 -0
  41. package/dist/convert/schema.js +77 -0
  42. package/dist/convert/schema.js.map +1 -0
  43. package/dist/core/parse.d.ts +24 -0
  44. package/dist/core/parse.d.ts.map +1 -0
  45. package/dist/core/parse.js +64 -0
  46. package/dist/core/parse.js.map +1 -0
  47. package/dist/diagnostics/adapt.d.ts +6 -0
  48. package/dist/diagnostics/adapt.d.ts.map +1 -0
  49. package/dist/diagnostics/adapt.js +81 -0
  50. package/dist/diagnostics/adapt.js.map +1 -0
  51. package/dist/diagnostics/format.d.ts +18 -0
  52. package/dist/diagnostics/format.d.ts.map +1 -0
  53. package/dist/diagnostics/format.js +41 -0
  54. package/dist/diagnostics/format.js.map +1 -0
  55. package/dist/diagnostics/index.d.ts +5 -0
  56. package/dist/diagnostics/index.d.ts.map +1 -0
  57. package/dist/diagnostics/index.js +5 -0
  58. package/dist/diagnostics/index.js.map +1 -0
  59. package/dist/diagnostics/json.d.ts +8 -0
  60. package/dist/diagnostics/json.d.ts.map +1 -0
  61. package/dist/diagnostics/json.js +24 -0
  62. package/dist/diagnostics/json.js.map +1 -0
  63. package/dist/diagnostics/model.d.ts +44 -0
  64. package/dist/diagnostics/model.d.ts.map +1 -0
  65. package/dist/diagnostics/model.js +2 -0
  66. package/dist/diagnostics/model.js.map +1 -0
  67. package/dist/diagnostics/text.d.ts +6 -0
  68. package/dist/diagnostics/text.d.ts.map +1 -0
  69. package/dist/diagnostics/text.js +43 -0
  70. package/dist/diagnostics/text.js.map +1 -0
  71. package/dist/generated/templates.d.ts +4 -0
  72. package/dist/generated/templates.d.ts.map +1 -0
  73. package/dist/generated/templates.js +10 -0
  74. package/dist/generated/templates.js.map +1 -0
  75. package/dist/generated/version.d.ts +11 -0
  76. package/dist/generated/version.d.ts.map +1 -0
  77. package/dist/generated/version.js +8 -0
  78. package/dist/generated/version.js.map +1 -0
  79. package/dist/i18n/locale.d.ts +56 -0
  80. package/dist/i18n/locale.d.ts.map +1 -0
  81. package/dist/i18n/locale.js +107 -0
  82. package/dist/i18n/locale.js.map +1 -0
  83. package/dist/index.d.ts +3 -0
  84. package/dist/index.d.ts.map +1 -0
  85. package/dist/index.js +60 -0
  86. package/dist/index.js.map +1 -0
  87. package/dist/io/config.d.ts +2 -0
  88. package/dist/io/config.d.ts.map +1 -0
  89. package/dist/io/config.js +5 -0
  90. package/dist/io/config.js.map +1 -0
  91. package/dist/io/exit-codes.d.ts +12 -0
  92. package/dist/io/exit-codes.d.ts.map +1 -0
  93. package/dist/io/exit-codes.js +15 -0
  94. package/dist/io/exit-codes.js.map +1 -0
  95. package/dist/io/read.d.ts +13 -0
  96. package/dist/io/read.d.ts.map +1 -0
  97. package/dist/io/read.js +53 -0
  98. package/dist/io/read.js.map +1 -0
  99. package/dist/io/write.d.ts +32 -0
  100. package/dist/io/write.d.ts.map +1 -0
  101. package/dist/io/write.js +61 -0
  102. package/dist/io/write.js.map +1 -0
  103. package/dist/version.d.ts +13 -0
  104. package/dist/version.d.ts.map +1 -0
  105. package/dist/version.js +20 -0
  106. package/dist/version.js.map +1 -0
  107. package/man/fr/nowline.1 +463 -0
  108. package/man/fr/nowline.5 +1864 -0
  109. package/man/nowline.1 +448 -0
  110. package/man/nowline.5 +1785 -0
  111. package/package.json +70 -0
  112. package/scripts/bundle-templates.mjs +106 -0
  113. package/scripts/compile.mjs +131 -0
  114. package/src/cli/args.ts +252 -0
  115. package/src/cli/formats.ts +207 -0
  116. package/src/cli/help.ts +92 -0
  117. package/src/cli/output-path.ts +98 -0
  118. package/src/commands/init.ts +99 -0
  119. package/src/commands/render.ts +567 -0
  120. package/src/commands/serve.ts +322 -0
  121. package/src/convert/parse-json.ts +57 -0
  122. package/src/convert/printer.ts +376 -0
  123. package/src/convert/schema.ts +105 -0
  124. package/src/core/parse.ts +97 -0
  125. package/src/diagnostics/adapt.ts +89 -0
  126. package/src/diagnostics/format.ts +70 -0
  127. package/src/diagnostics/index.ts +4 -0
  128. package/src/diagnostics/json.ts +30 -0
  129. package/src/diagnostics/model.ts +48 -0
  130. package/src/diagnostics/text.ts +62 -0
  131. package/src/generated/templates.ts +13 -0
  132. package/src/generated/version.ts +18 -0
  133. package/src/i18n/locale.ts +133 -0
  134. package/src/index.ts +60 -0
  135. package/src/io/config.ts +11 -0
  136. package/src/io/exit-codes.ts +18 -0
  137. package/src/io/read.ts +70 -0
  138. package/src/io/write.ts +94 -0
  139. package/src/version.ts +21 -0
@@ -0,0 +1,376 @@
1
+ import { CliError, ExitCode } from '../io/exit-codes.js';
2
+ import type { JsonAstNode } from './schema.js';
3
+
4
+ // Keyed-property canonical order. Keys not in this list sort alphabetically after.
5
+ const KEY_ORDER = [
6
+ 'date',
7
+ 'effort',
8
+ 'on',
9
+ 'size',
10
+ 'duration',
11
+ 'status',
12
+ 'owner',
13
+ 'after',
14
+ 'before',
15
+ 'remaining',
16
+ 'labels',
17
+ 'style',
18
+ 'link',
19
+ 'author',
20
+ 'start',
21
+ 'scale',
22
+ 'calendar',
23
+ 'header-position',
24
+ 'timeline-position',
25
+ 'minor-grid',
26
+ ];
27
+
28
+ const INDENT = ' ';
29
+
30
+ export interface PrintOptions {
31
+ indent?: string;
32
+ }
33
+
34
+ export function printNowlineFile(ast: JsonAstNode, options: PrintOptions = {}): string {
35
+ const printer = new Printer(options.indent ?? INDENT);
36
+ printer.file(ast);
37
+ return printer.toString();
38
+ }
39
+
40
+ class Printer {
41
+ private readonly lines: string[] = [];
42
+
43
+ constructor(private readonly indent: string) {}
44
+
45
+ toString(): string {
46
+ const text = this.lines.join('\n');
47
+ return text.endsWith('\n') ? text : `${text}\n`;
48
+ }
49
+
50
+ file(file: JsonAstNode): void {
51
+ assertType(file, 'NowlineFile');
52
+ const directive = file.directive as JsonAstNode | undefined;
53
+ if (directive) {
54
+ const props = asArray(directive.properties);
55
+ const tail = props.length > 0 ? ` ${props.map(renderProperty).join(' ')}` : '';
56
+ this.line(0, `nowline ${getString(directive, 'version')}${tail}`);
57
+ this.blank();
58
+ }
59
+ for (const inc of asArray(file.includes)) {
60
+ this.include(inc);
61
+ }
62
+ if (asArray(file.includes).length > 0) this.blank();
63
+ if (file.hasConfig) {
64
+ this.line(0, 'config');
65
+ this.blank();
66
+ for (const entry of asArray(file.configEntries)) {
67
+ this.configEntry(entry);
68
+ this.blank();
69
+ }
70
+ }
71
+ if (file.roadmapDecl) {
72
+ this.roadmap(file.roadmapDecl as JsonAstNode);
73
+ this.blank();
74
+ }
75
+ for (const entry of asArray(file.roadmapEntries)) {
76
+ this.roadmapEntry(entry, 0);
77
+ }
78
+ }
79
+
80
+ include(inc: JsonAstNode): void {
81
+ assertType(inc, 'IncludeDeclaration');
82
+ const path = getString(inc, 'path');
83
+ const options = asArray(inc.options)
84
+ .map((o) => `${getString(o, 'key')}:${getString(o, 'value')}`)
85
+ .join(' ');
86
+ const tail = options ? ` ${options}` : '';
87
+ this.line(0, `include ${JSON.stringify(path)}${tail}`);
88
+ }
89
+
90
+ configEntry(entry: JsonAstNode): void {
91
+ switch (entry.$type) {
92
+ case 'ScaleBlock':
93
+ return this.blockDecl('scale', asArray(entry.properties));
94
+ case 'CalendarBlock':
95
+ return this.blockDecl('calendar', asArray(entry.properties));
96
+ case 'StyleDeclaration':
97
+ return this.styleDecl(entry);
98
+ case 'DefaultDeclaration':
99
+ return this.defaultDecl(entry);
100
+ default:
101
+ throw new CliError(
102
+ ExitCode.ValidationError,
103
+ `Unknown config entry type: ${String(entry.$type)}`,
104
+ );
105
+ }
106
+ }
107
+
108
+ blockDecl(keyword: string, properties: JsonAstNode[]): void {
109
+ this.line(0, keyword);
110
+ for (const p of properties) {
111
+ this.line(1, renderBlockProperty(p));
112
+ }
113
+ }
114
+
115
+ styleDecl(entry: JsonAstNode): void {
116
+ assertType(entry, 'StyleDeclaration');
117
+ const header = declarationHeader('style', entry, []);
118
+ this.line(0, header);
119
+ for (const p of asArray(entry.properties)) {
120
+ this.line(1, renderBlockProperty(p));
121
+ }
122
+ }
123
+
124
+ defaultDecl(entry: JsonAstNode): void {
125
+ assertType(entry, 'DefaultDeclaration');
126
+ const entityType = getString(entry, 'entityType');
127
+ const props = renderProperties(asArray(entry.properties));
128
+ const tail = props ? ` ${props}` : '';
129
+ this.line(0, `default ${entityType}${tail}`);
130
+ }
131
+
132
+ roadmap(decl: JsonAstNode): void {
133
+ assertType(decl, 'RoadmapDeclaration');
134
+ this.line(0, declarationHeader('roadmap', decl, asArray(decl.properties)));
135
+ }
136
+
137
+ roadmapEntry(entry: JsonAstNode, depth: number): void {
138
+ switch (entry.$type) {
139
+ case 'PersonDeclaration':
140
+ return this.simpleEntity('person', entry, depth);
141
+ case 'TeamDeclaration':
142
+ return this.team(entry, depth);
143
+ case 'AnchorDeclaration':
144
+ return this.simpleEntity('anchor', entry, depth);
145
+ case 'SizeDeclaration':
146
+ return this.simpleEntity('size', entry, depth);
147
+ case 'StatusDeclaration':
148
+ return this.simpleEntity('status', entry, depth);
149
+ case 'LabelDeclaration':
150
+ return this.simpleEntity('label', entry, depth);
151
+ case 'MilestoneDeclaration':
152
+ return this.simpleEntity('milestone', entry, depth);
153
+ case 'FootnoteDeclaration':
154
+ return this.simpleEntity('footnote', entry, depth);
155
+ case 'SwimlaneDeclaration':
156
+ return this.swimlane(entry, depth);
157
+ default:
158
+ throw new CliError(
159
+ ExitCode.ValidationError,
160
+ `Unknown roadmap entry type: ${String(entry.$type)}`,
161
+ );
162
+ }
163
+ }
164
+
165
+ simpleEntity(keyword: string, entry: JsonAstNode, depth: number): void {
166
+ this.line(depth, declarationHeader(keyword, entry, asArray(entry.properties)));
167
+ this.maybeDescription(entry, depth + 1);
168
+ }
169
+
170
+ team(entry: JsonAstNode, depth: number): void {
171
+ this.line(depth, declarationHeader('team', entry, asArray(entry.properties)));
172
+ for (const child of asArray(entry.content)) {
173
+ this.teamContent(child, depth + 1);
174
+ }
175
+ }
176
+
177
+ teamContent(node: JsonAstNode, depth: number): void {
178
+ if (node.$type === 'PersonMemberRef') {
179
+ this.line(depth, `person ${getString(node, 'ref')}`);
180
+ return;
181
+ }
182
+ if (node.$type === 'TeamDeclaration') {
183
+ return this.team(node, depth);
184
+ }
185
+ if (node.$type === 'PersonDeclaration') {
186
+ return this.simpleEntity('person', node, depth);
187
+ }
188
+ if (node.$type === 'DescriptionDirective') {
189
+ return this.descriptionDirective(node, depth);
190
+ }
191
+ throw new CliError(
192
+ ExitCode.ValidationError,
193
+ `Unknown team content type: ${String(node.$type)}`,
194
+ );
195
+ }
196
+
197
+ swimlane(entry: JsonAstNode, depth: number): void {
198
+ this.line(depth, declarationHeader('swimlane', entry, asArray(entry.properties)));
199
+ for (const child of asArray(entry.content)) {
200
+ this.swimlaneContent(child, depth + 1);
201
+ }
202
+ }
203
+
204
+ swimlaneContent(node: JsonAstNode, depth: number): void {
205
+ switch (node.$type) {
206
+ case 'ItemDeclaration':
207
+ this.simpleEntity('item', node, depth);
208
+ return;
209
+ case 'ParallelBlock':
210
+ this.parallelBlock(node, depth);
211
+ return;
212
+ case 'GroupBlock':
213
+ this.groupBlock(node, depth);
214
+ return;
215
+ case 'DescriptionDirective':
216
+ this.descriptionDirective(node, depth);
217
+ return;
218
+ default:
219
+ throw new CliError(
220
+ ExitCode.ValidationError,
221
+ `Unknown swimlane content type: ${String(node.$type)}`,
222
+ );
223
+ }
224
+ }
225
+
226
+ parallelBlock(entry: JsonAstNode, depth: number): void {
227
+ this.line(depth, declarationHeader('parallel', entry, asArray(entry.properties)));
228
+ for (const child of asArray(entry.content)) {
229
+ this.swimlaneContent(child, depth + 1);
230
+ }
231
+ }
232
+
233
+ groupBlock(entry: JsonAstNode, depth: number): void {
234
+ this.line(depth, declarationHeader('group', entry, asArray(entry.properties)));
235
+ for (const child of asArray(entry.content)) {
236
+ this.swimlaneContent(child, depth + 1);
237
+ }
238
+ }
239
+
240
+ descriptionDirective(node: JsonAstNode, depth: number): void {
241
+ assertType(node, 'DescriptionDirective');
242
+ this.line(depth, `description ${JSON.stringify(getString(node, 'text'))}`);
243
+ }
244
+
245
+ maybeDescription(entry: JsonAstNode, depth: number): void {
246
+ const desc = entry.description as JsonAstNode | undefined;
247
+ if (desc) this.descriptionDirective(desc, depth);
248
+ }
249
+
250
+ private line(depth: number, text: string): void {
251
+ this.lines.push(this.indent.repeat(depth) + text);
252
+ }
253
+
254
+ private blank(): void {
255
+ if (this.lines.length === 0) return;
256
+ if (this.lines[this.lines.length - 1] === '') return;
257
+ this.lines.push('');
258
+ }
259
+ }
260
+
261
+ function declarationHeader(keyword: string, entry: JsonAstNode, properties: JsonAstNode[]): string {
262
+ const id = entry.name as string | undefined;
263
+ const title = entry.title as string | undefined;
264
+ const parts = [keyword];
265
+ if (id) parts.push(id);
266
+ if (title) parts.push(JSON.stringify(title));
267
+ const props = renderProperties(properties);
268
+ if (props) parts.push(props);
269
+ return parts.join(' ');
270
+ }
271
+
272
+ function renderProperties(properties: JsonAstNode[]): string {
273
+ return orderProperties(properties).map(renderProperty).join(' ');
274
+ }
275
+
276
+ function orderProperties(properties: JsonAstNode[]): JsonAstNode[] {
277
+ const indexOf = new Map(KEY_ORDER.map((k, i) => [k, i] as const));
278
+ return [...properties].sort((a, b) => {
279
+ const ak = normalizeKey(getString(a, 'key'));
280
+ const bk = normalizeKey(getString(b, 'key'));
281
+ const ai = indexOf.get(ak);
282
+ const bi = indexOf.get(bk);
283
+ if (ai !== undefined && bi !== undefined) return ai - bi;
284
+ if (ai !== undefined) return -1;
285
+ if (bi !== undefined) return 1;
286
+ return ak.localeCompare(bk);
287
+ });
288
+ }
289
+
290
+ function renderProperty(prop: JsonAstNode): string {
291
+ const key = normalizeKey(getString(prop, 'key'));
292
+ const values = asStringArray(prop.values);
293
+ const value = prop.value as string | undefined;
294
+ if (values.length >= 2) {
295
+ return `${key}:[${values.map(formatAtom).join(', ')}]`;
296
+ }
297
+ if (values.length === 1) {
298
+ return `${key}:${formatAtom(values[0])}`;
299
+ }
300
+ if (value !== undefined && value !== '') {
301
+ return `${key}:${formatAtom(value)}`;
302
+ }
303
+ return `${key}:`;
304
+ }
305
+
306
+ // Block-style property (one per line, rendered as `key: value`). Used inside
307
+ // indented blocks like `scale`, `calendar`, and `style`. Uses formatAtom so
308
+ // that quoted / template strings such as `label: "W{n}"` survive a round-trip.
309
+ function renderBlockProperty(prop: JsonAstNode): string {
310
+ const key = normalizeKey(getString(prop, 'key'));
311
+ const values = asStringArray(prop.values);
312
+ const value = prop.value as string | undefined;
313
+ if (values.length >= 2) {
314
+ return `${key}: [${values.map(formatAtom).join(', ')}]`;
315
+ }
316
+ if (values.length === 1) {
317
+ return `${key}: ${formatAtom(values[0])}`;
318
+ }
319
+ if (value !== undefined && value !== '') {
320
+ return `${key}: ${formatAtom(value)}`;
321
+ }
322
+ return `${key}:`;
323
+ }
324
+
325
+ function normalizeKey(key: string): string {
326
+ return key.endsWith(':') ? key.slice(0, -1) : key;
327
+ }
328
+
329
+ const URL_RE = /^https?:\/\//;
330
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
331
+ const DURATION_RE = /^\d+(?:\.\d+)?[dwmqy]$/;
332
+ const PERCENTAGE_RE = /^\d+%$/;
333
+ const HEX_COLOR_RE = /^#[0-9a-fA-F]{3,8}$/;
334
+ const INTEGER_RE = /^\d+$/;
335
+ const ID_RE = /^[a-zA-Z_][a-zA-Z0-9_-]*$/;
336
+
337
+ function formatAtom(atom: string): string {
338
+ if (
339
+ URL_RE.test(atom) ||
340
+ DATE_RE.test(atom) ||
341
+ DURATION_RE.test(atom) ||
342
+ PERCENTAGE_RE.test(atom) ||
343
+ HEX_COLOR_RE.test(atom) ||
344
+ INTEGER_RE.test(atom) ||
345
+ ID_RE.test(atom)
346
+ ) {
347
+ return atom;
348
+ }
349
+ // Already-quoted string survives a round trip through JSON.stringify by re-quoting once.
350
+ if (atom.startsWith('"') && atom.endsWith('"')) return atom;
351
+ return JSON.stringify(atom);
352
+ }
353
+
354
+ function asArray(value: unknown): JsonAstNode[] {
355
+ if (Array.isArray(value)) return value as JsonAstNode[];
356
+ return [];
357
+ }
358
+
359
+ function asStringArray(value: unknown): string[] {
360
+ if (!Array.isArray(value)) return [];
361
+ return value.filter((v): v is string => typeof v === 'string');
362
+ }
363
+
364
+ function getString(node: JsonAstNode | Record<string, unknown>, key: string): string {
365
+ const value = (node as Record<string, unknown>)[key];
366
+ return typeof value === 'string' ? value : '';
367
+ }
368
+
369
+ function assertType(node: JsonAstNode, expected: string): void {
370
+ if (node.$type !== expected) {
371
+ throw new CliError(
372
+ ExitCode.ValidationError,
373
+ `Expected $type "${expected}", got "${String(node.$type)}"`,
374
+ );
375
+ }
376
+ }
@@ -0,0 +1,105 @@
1
+ import type { NowlineFile } from '@nowline/core';
2
+ import type { AstNode, CstNode, LangiumDocument } from 'langium';
3
+
4
+ export const NOWLINE_SCHEMA_VERSION = '1';
5
+
6
+ export interface NowlineDocument {
7
+ $nowlineSchema: string;
8
+ file: { uri: string; source: string };
9
+ ast: JsonAstNode;
10
+ }
11
+
12
+ export interface Position {
13
+ start: { line: number; column: number; offset: number };
14
+ end: { line: number; column: number; offset: number };
15
+ }
16
+
17
+ export interface JsonAstNode {
18
+ $type: string;
19
+ $position?: Position;
20
+ [key: string]: unknown;
21
+ }
22
+
23
+ export interface SerializeOptions {
24
+ includePositions?: boolean;
25
+ }
26
+
27
+ // Keys that Langium adds to AST nodes that we don't want in the JSON form.
28
+ const CONTAINER_KEYS = new Set(['$container', '$containerProperty', '$containerIndex']);
29
+ // $cstNode / $document are runtime-only. $type and $position are emitted.
30
+ const RUNTIME_KEYS = new Set(['$cstNode', '$document']);
31
+
32
+ export function serializeToJson(
33
+ document: LangiumDocument<NowlineFile>,
34
+ source: string,
35
+ options: SerializeOptions = {},
36
+ ): NowlineDocument {
37
+ const includePositions = options.includePositions ?? true;
38
+ return {
39
+ $nowlineSchema: NOWLINE_SCHEMA_VERSION,
40
+ file: {
41
+ uri: document.uri.toString(),
42
+ source,
43
+ },
44
+ ast: serializeNode(document.parseResult.value, includePositions),
45
+ };
46
+ }
47
+
48
+ function serializeNode(node: AstNode, includePositions: boolean): JsonAstNode {
49
+ const out: JsonAstNode = { $type: node.$type };
50
+ if (includePositions) {
51
+ const pos = cstPosition(node.$cstNode);
52
+ if (pos) out.$position = pos;
53
+ }
54
+ for (const [key, value] of Object.entries(node)) {
55
+ if (key.startsWith('$')) continue;
56
+ if (CONTAINER_KEYS.has(key) || RUNTIME_KEYS.has(key)) continue;
57
+ out[key] = serializeValue(value, includePositions);
58
+ }
59
+ return out;
60
+ }
61
+
62
+ function serializeValue(value: unknown, includePositions: boolean): unknown {
63
+ if (value === null || value === undefined) return value;
64
+ if (Array.isArray(value)) {
65
+ return value.map((v) => serializeValue(v, includePositions));
66
+ }
67
+ if (isAstNode(value)) {
68
+ return serializeNode(value, includePositions);
69
+ }
70
+ if (typeof value === 'object') {
71
+ const record = value as Record<string, unknown>;
72
+ const out: Record<string, unknown> = {};
73
+ for (const [k, v] of Object.entries(record)) {
74
+ if (k.startsWith('$')) continue;
75
+ if (CONTAINER_KEYS.has(k) || RUNTIME_KEYS.has(k)) continue;
76
+ out[k] = serializeValue(v, includePositions);
77
+ }
78
+ return out;
79
+ }
80
+ return value;
81
+ }
82
+
83
+ function isAstNode(value: unknown): value is AstNode {
84
+ return (
85
+ value !== null &&
86
+ typeof value === 'object' &&
87
+ typeof (value as { $type?: unknown }).$type === 'string'
88
+ );
89
+ }
90
+
91
+ function cstPosition(cst: CstNode | undefined): Position | undefined {
92
+ if (!cst) return undefined;
93
+ return {
94
+ start: {
95
+ line: cst.range.start.line + 1,
96
+ column: cst.range.start.character + 1,
97
+ offset: cst.offset,
98
+ },
99
+ end: {
100
+ line: cst.range.end.line + 1,
101
+ column: cst.range.end.character + 1,
102
+ offset: cst.end,
103
+ },
104
+ };
105
+ }
@@ -0,0 +1,97 @@
1
+ import {
2
+ collectDocumentDiagnostics,
3
+ createNowlineServices,
4
+ type NowlineFile,
5
+ type NowlineServices,
6
+ } from '@nowline/core';
7
+ import { type LangiumDocument, URI } from 'langium';
8
+ import { adaptLangiumDiagnostic, adaptLexerError, adaptParserError } from '../diagnostics/adapt.js';
9
+ import type { CliDiagnostic, DiagnosticSource } from '../diagnostics/model.js';
10
+
11
+ export interface ParseOptions {
12
+ validate?: boolean;
13
+ }
14
+
15
+ export interface ParseResult {
16
+ ast: NowlineFile;
17
+ document: LangiumDocument<NowlineFile>;
18
+ diagnostics: CliDiagnostic[];
19
+ /** True if any diagnostic (parse, lex, or semantic validation) has severity 'error'. */
20
+ hasErrors: boolean;
21
+ /** True if any *parser/lexer* diagnostic is present. Semantic validation errors don't count. */
22
+ hasParseErrors: boolean;
23
+ source: DiagnosticSource;
24
+ }
25
+
26
+ type Services = {
27
+ shared: ReturnType<typeof createNowlineServices>['shared'];
28
+ Nowline: NowlineServices;
29
+ };
30
+
31
+ let cachedServices: Services | undefined;
32
+ let docCounter = 0;
33
+
34
+ export function getServices(): Services {
35
+ if (!cachedServices) cachedServices = createNowlineServices();
36
+ return cachedServices;
37
+ }
38
+
39
+ export async function parseSource(
40
+ contents: string,
41
+ filePath: string,
42
+ options: ParseOptions = {},
43
+ ): Promise<ParseResult> {
44
+ const services = getServices();
45
+ const uri = uriFor(filePath);
46
+ const docFactory = services.shared.workspace.LangiumDocumentFactory;
47
+ const doc = docFactory.fromString<NowlineFile>(contents, uri);
48
+ await services.shared.workspace.DocumentBuilder.build([doc], {
49
+ validation: options.validate ?? true,
50
+ });
51
+
52
+ const source: DiagnosticSource = { file: filePath, contents };
53
+ const diagnostics: CliDiagnostic[] = [];
54
+ const parseDiagnostics: CliDiagnostic[] = [];
55
+
56
+ // collectDocumentDiagnostics owns the de-dup: Langium re-folds lexer +
57
+ // parser errors into doc.diagnostics, so the shared collector skips those
58
+ // copies. Lexer/parser rows also feed `parseDiagnostics` (drives the
59
+ // hasParseErrors gate that suppresses downstream layout/render).
60
+ for (const raw of collectDocumentDiagnostics(doc)) {
61
+ if (raw.origin === 'lexer') {
62
+ const adapted = adaptLexerError(raw.error, filePath);
63
+ parseDiagnostics.push(adapted);
64
+ diagnostics.push(adapted);
65
+ } else if (raw.origin === 'parser') {
66
+ const adapted = adaptParserError(raw.error, filePath);
67
+ parseDiagnostics.push(adapted);
68
+ diagnostics.push(adapted);
69
+ } else {
70
+ diagnostics.push(adaptLangiumDiagnostic(raw.diagnostic, filePath));
71
+ }
72
+ }
73
+
74
+ const hasErrors = diagnostics.some((d) => d.severity === 'error');
75
+ const hasParseErrors = parseDiagnostics.some((d) => d.severity === 'error');
76
+ return {
77
+ ast: doc.parseResult.value,
78
+ document: doc,
79
+ diagnostics,
80
+ hasErrors,
81
+ hasParseErrors,
82
+ source,
83
+ };
84
+ }
85
+
86
+ function uriFor(filePath: string): URI {
87
+ // Always mint a fresh URI for each parse. Re-using a URI across calls causes
88
+ // Langium's DocumentBuilder to mutate the prior document and/or rebind
89
+ // cross-references against an incompatible AST, which surfaces as spurious
90
+ // diagnostics when the CLI is embedded in a long-running process (tests,
91
+ // language server, etc.). The suffix ensures `build([doc])` is always a net
92
+ // new document.
93
+ if (filePath === '<stdin>') {
94
+ return URI.parse(`memory:///stdin-${++docCounter}.nowline`);
95
+ }
96
+ return URI.parse(`memory:///doc-${++docCounter}.nowline`);
97
+ }
@@ -0,0 +1,89 @@
1
+ import {
2
+ extractSuggestion,
3
+ type LangiumLikeDiagnostic,
4
+ type LexerErrorLike,
5
+ type ParserErrorLike,
6
+ resolveDiagnosticCode,
7
+ } from '@nowline/core';
8
+ import type { CliDiagnostic, DiagnosticSeverity, LocalizedMessageData } from './model.js';
9
+
10
+ export function adaptLangiumDiagnostic(diag: LangiumLikeDiagnostic, file: string): CliDiagnostic {
11
+ const severity = mapSeverity(diag.severity);
12
+ const line = (diag.range?.start.line ?? 0) + 1;
13
+ const column = (diag.range?.start.character ?? 0) + 1;
14
+ return {
15
+ file,
16
+ line,
17
+ column,
18
+ severity,
19
+ // resolveDiagnosticCode prefers the stable validator code carried in
20
+ // `data`, then Langium's `code`, then a message heuristic.
21
+ code: resolveDiagnosticCode(diag),
22
+ message: diag.message,
23
+ span: diag.range
24
+ ? {
25
+ start: { line, column },
26
+ end: {
27
+ line: (diag.range.end.line ?? 0) + 1,
28
+ column: (diag.range.end.character ?? 0) + 1,
29
+ },
30
+ }
31
+ : undefined,
32
+ suggestion: extractSuggestion(diag.message),
33
+ // The CLI keeps the full { code, args } so formatDiagnostics can
34
+ // re-render the message in the operator's locale at print time.
35
+ data: extractMessageData(diag.data),
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Validate the shape of `diag.data`. Validator-emitted diagnostics
41
+ * stash `{ code: MessageCode, args: MessageArgs<K> }` (where `args` is
42
+ * the spread tuple `[]` or `[{...}]`). Anything else (vscode code
43
+ * actions, third-party data, etc.) is ignored.
44
+ */
45
+ function extractMessageData(data: unknown): LocalizedMessageData | undefined {
46
+ if (!data || typeof data !== 'object') return undefined;
47
+ const obj = data as { code?: unknown; args?: unknown };
48
+ if (typeof obj.code !== 'string') return undefined;
49
+ if (!Array.isArray(obj.args)) return undefined;
50
+ return { code: obj.code, args: obj.args };
51
+ }
52
+
53
+ export function adaptParserError(err: ParserErrorLike, file: string): CliDiagnostic {
54
+ const line = err.token?.startLine ?? 1;
55
+ const column = err.token?.startColumn ?? 1;
56
+ const endLine = err.token?.endLine ?? line;
57
+ const endColumn = (err.token?.endColumn ?? column) + 1;
58
+ return {
59
+ file,
60
+ line,
61
+ column,
62
+ severity: 'error',
63
+ code: 'parse-error',
64
+ message: err.message,
65
+ span: { start: { line, column }, end: { line: endLine, column: endColumn } },
66
+ };
67
+ }
68
+
69
+ export function adaptLexerError(err: LexerErrorLike, file: string): CliDiagnostic {
70
+ const line = err.line ?? 1;
71
+ const column = err.column ?? 1;
72
+ const length = err.length ?? 1;
73
+ return {
74
+ file,
75
+ line,
76
+ column,
77
+ severity: 'error',
78
+ code: 'lex-error',
79
+ message: err.message,
80
+ span: {
81
+ start: { line, column },
82
+ end: { line, column: column + Math.max(length, 1) },
83
+ },
84
+ };
85
+ }
86
+
87
+ function mapSeverity(severity: number | undefined): DiagnosticSeverity {
88
+ return severity === 2 ? 'warning' : 'error';
89
+ }