@hydranium/conformance 1.0.0-next.11 → 1.0.0-next.112
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.
- package/README.md +16 -5
- package/lib/data/index.d.ts +16 -5
- package/lib/data/index.d.ts.map +1 -1
- package/lib/data/index.js +185 -12
- package/lib/data/index.js.map +1 -1
- package/lib/lsp/index.d.ts +22 -2
- package/lib/lsp/index.d.ts.map +1 -1
- package/lib/lsp/index.js +82 -6
- package/lib/lsp/index.js.map +1 -1
- package/lib/model.d.ts +105 -0
- package/lib/model.d.ts.map +1 -1
- package/lib/model.js.map +1 -1
- package/package.json +3 -3
- package/src/data/index.ts +246 -16
- package/src/lsp/index.ts +122 -9
- package/src/model.ts +108 -0
package/src/lsp/index.ts
CHANGED
|
@@ -11,10 +11,11 @@
|
|
|
11
11
|
* The `@hydranium/conformance/lsp` slice — protocol conformance for the LSP
|
|
12
12
|
* head. The driver port is a STRUCTURAL minimum (`LspConformanceDriver`) that
|
|
13
13
|
* `@hydranium/core/testing/node`'s `LspHarness` satisfies with NO adapter: the kit
|
|
14
|
-
* names only
|
|
14
|
+
* names only strings, plain coordinates, and tiny structural minima
|
|
15
15
|
* (`{ message }`, `{ items }`, `{ capabilities }`), never
|
|
16
16
|
* `vscode-languageserver-protocol` wire types, so the harness's richer return
|
|
17
|
-
* types are assignable to the narrower port.
|
|
17
|
+
* types are assignable to the narrower port. An alias of `string` is still a
|
|
18
|
+
* string for that purpose and stays inside the rule; a wire type does not.
|
|
18
19
|
*
|
|
19
20
|
* **This slice does not read `LanguageFixture.edit` at all.** The didChange
|
|
20
21
|
* check drives `valid → invalid` using `invalid.text`, so there is no
|
|
@@ -23,9 +24,21 @@
|
|
|
23
24
|
*/
|
|
24
25
|
|
|
25
26
|
import assert from 'node:assert/strict';
|
|
27
|
+
import type { Locale } from '@hydranium/protocol';
|
|
26
28
|
import type { Harness } from '@hydranium/protocol/testing';
|
|
27
29
|
import type { ConformanceCheck } from '../conformance-suite.js';
|
|
28
|
-
import { type LanguageFixture, resolveDeferred, resolveModel } from '../model.js';
|
|
30
|
+
import { type ConformanceModel, type LanguageFixture, resolveDeferred, resolveModel } from '../model.js';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Structural minimum of the `initialize` params the kit sends — only `locale`,
|
|
34
|
+
* which is the one field a check needs to vary.
|
|
35
|
+
*
|
|
36
|
+
* A real `InitializeParams` is assignable to this, so a driver typed against
|
|
37
|
+
* upstream's shape satisfies the port with no adapter.
|
|
38
|
+
*/
|
|
39
|
+
export interface LspConformanceInitializeParams {
|
|
40
|
+
readonly locale?: Locale;
|
|
41
|
+
}
|
|
29
42
|
|
|
30
43
|
/** Structural minimum of an LSP `InitializeResult` — only the baseline capabilities the kit asserts. */
|
|
31
44
|
export interface LspConformanceInitializeResult {
|
|
@@ -51,10 +64,20 @@ export interface LspConformanceDiagnostic {
|
|
|
51
64
|
readonly message: string | { readonly value: string };
|
|
52
65
|
}
|
|
53
66
|
|
|
54
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* True when `diagnostic` carries a NON-EMPTY message in either LSP shape
|
|
69
|
+
* (plain string or markup).
|
|
70
|
+
*
|
|
71
|
+
* Emptiness is part of the claim, not a refinement of it: `message: ''` is a
|
|
72
|
+
* diagnostic a user cannot act on, and a type-only test admits it — which
|
|
73
|
+
* makes "every diagnostic carries a message" pass for a head that publishes
|
|
74
|
+
* none. LSP declares the field required without forbidding the empty string,
|
|
75
|
+
* so nothing upstream rules it out either.
|
|
76
|
+
*/
|
|
55
77
|
function hasTextMessage(diagnostic: LspConformanceDiagnostic): boolean {
|
|
56
78
|
const { message } = diagnostic;
|
|
57
|
-
|
|
79
|
+
const text = typeof message === 'string' ? message : message?.value;
|
|
80
|
+
return typeof text === 'string' && text.length > 0;
|
|
58
81
|
}
|
|
59
82
|
|
|
60
83
|
/**
|
|
@@ -66,8 +89,17 @@ function hasTextMessage(diagnostic: LspConformanceDiagnostic): boolean {
|
|
|
66
89
|
* gives the kit the universal `dispose()` teardown.
|
|
67
90
|
*/
|
|
68
91
|
export interface LspConformanceDriver extends Harness {
|
|
69
|
-
/**
|
|
70
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Drive the `initialize` → `initialized` handshake; resolve with the
|
|
94
|
+
* (structurally-minimal) result.
|
|
95
|
+
*
|
|
96
|
+
* `params` is optional and every field in it is too, so a driver that ignores
|
|
97
|
+
* the argument entirely still satisfies this port — which is what keeps
|
|
98
|
+
* adding a field here from being a breaking change. `locale` is the reading
|
|
99
|
+
* user's language, declared by the client because only the client knows it;
|
|
100
|
+
* the render check below is the only caller that passes anything.
|
|
101
|
+
*/
|
|
102
|
+
initialize(params?: LspConformanceInitializeParams): Promise<LspConformanceInitializeResult>;
|
|
71
103
|
/** Send `didOpen` for `uri` with full `text` under `languageId`. */
|
|
72
104
|
openDocument(uri: string, text: string, languageId: string, version?: number): void;
|
|
73
105
|
/** Send `didChange` for `uri` as a single full-text replacement at `version`. */
|
|
@@ -167,7 +199,7 @@ export function buildLspChecks(options: LspConformanceOptions): ConformanceCheck
|
|
|
167
199
|
});
|
|
168
200
|
|
|
169
201
|
for (const language of options.languages) {
|
|
170
|
-
const { valid, invalid, completionPosition } = language;
|
|
202
|
+
const { valid, invalid, completionPosition, renderedDiagnostic } = language;
|
|
171
203
|
const tag = `[${valid.languageId}]`;
|
|
172
204
|
|
|
173
205
|
checks.push({
|
|
@@ -199,7 +231,7 @@ export function buildLspChecks(options: LspConformanceOptions): ConformanceCheck
|
|
|
199
231
|
driver.openDocument(model.uri, model.text, model.languageId);
|
|
200
232
|
const published = await diagnostics;
|
|
201
233
|
assert.ok(published.length >= 1, 'didOpen(invalid) published no diagnostics');
|
|
202
|
-
assert.ok(published.every(hasTextMessage), 'a published diagnostic was missing a message');
|
|
234
|
+
assert.ok(published.every(hasTextMessage), 'a published diagnostic was missing a non-empty message');
|
|
203
235
|
} finally {
|
|
204
236
|
driver.dispose();
|
|
205
237
|
}
|
|
@@ -263,7 +295,88 @@ export function buildLspChecks(options: LspConformanceOptions): ConformanceCheck
|
|
|
263
295
|
skipReason: 'fixture supplied no completionPosition'
|
|
264
296
|
});
|
|
265
297
|
}
|
|
298
|
+
|
|
299
|
+
// Opt-in: server-side rendering runs only when the fixture names a locale
|
|
300
|
+
// and the sentence it expects in it. The framework ships no catalogue, so
|
|
301
|
+
// a server that renders nothing is CORRECT and must not be failed.
|
|
302
|
+
const renderTitle = `a diagnostic is published rendered in the locale initialize declared ${tag}`;
|
|
303
|
+
const renderControlTitle = `the same diagnostic is NOT rendered when no locale is declared ${tag}`;
|
|
304
|
+
if (renderedDiagnostic) {
|
|
305
|
+
checks.push({
|
|
306
|
+
title: renderTitle,
|
|
307
|
+
body: async () => {
|
|
308
|
+
const published = await publishedMessagesFor(connect, invalid, { locale: renderedDiagnostic.locale });
|
|
309
|
+
assert.ok(
|
|
310
|
+
published.some(message => message.includes(renderedDiagnostic.expected)),
|
|
311
|
+
`no published diagnostic contained ${JSON.stringify(renderedDiagnostic.expected)}: ${JSON.stringify(published)}`
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// The second half of the pair. "Contains X" also passes for a server
|
|
317
|
+
// whose English contains X, and for one that renders whatever the
|
|
318
|
+
// locale — so the discriminating read is the fragment that must
|
|
319
|
+
// disappear. Reported as skipped rather than silently dropped when the
|
|
320
|
+
// fixture omits it, because a lone containment check IS weaker and a
|
|
321
|
+
// reader has to be able to see that from the report.
|
|
322
|
+
if (renderedDiagnostic.absentWithLocale) {
|
|
323
|
+
const { absentWithLocale } = renderedDiagnostic;
|
|
324
|
+
checks.push({
|
|
325
|
+
title: renderControlTitle,
|
|
326
|
+
body: async () => {
|
|
327
|
+
const withLocale = await publishedMessagesFor(connect, invalid, { locale: renderedDiagnostic.locale });
|
|
328
|
+
assert.ok(
|
|
329
|
+
!withLocale.some(message => message.includes(absentWithLocale)),
|
|
330
|
+
`a diagnostic still contained ${JSON.stringify(absentWithLocale)} with the locale declared: ${JSON.stringify(withLocale)}`
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
// And present without it, which is what rules out a fragment
|
|
334
|
+
// that never appears in either state — a typo in the fixture
|
|
335
|
+
// would otherwise make the assertion above pass for free.
|
|
336
|
+
const withoutLocale = await publishedMessagesFor(connect, invalid);
|
|
337
|
+
assert.ok(
|
|
338
|
+
withoutLocale.some(message => message.includes(absentWithLocale)),
|
|
339
|
+
`no diagnostic contained ${JSON.stringify(absentWithLocale)} with no locale declared, so it cannot witness the render: ${JSON.stringify(withoutLocale)}`
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
} else {
|
|
344
|
+
checks.push({
|
|
345
|
+
title: renderControlTitle,
|
|
346
|
+
skipReason: 'fixture supplied no absentWithLocale, so the render check is a containment test only'
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
} else {
|
|
350
|
+
checks.push({ title: renderTitle, skipReason: 'fixture supplied no renderedDiagnostic (server-side rendering is opt-in)' });
|
|
351
|
+
checks.push({ title: renderControlTitle, skipReason: 'fixture supplied no renderedDiagnostic (server-side rendering is opt-in)' });
|
|
352
|
+
}
|
|
266
353
|
}
|
|
267
354
|
|
|
268
355
|
return checks;
|
|
269
356
|
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Open `model` on a fresh driver and return the messages of the diagnostics
|
|
360
|
+
* published for it, as plain strings.
|
|
361
|
+
*
|
|
362
|
+
* A fresh driver per call because `initialize` is once-only per connection and
|
|
363
|
+
* the locale rides it — so the two states this compares cannot share one.
|
|
364
|
+
*/
|
|
365
|
+
async function publishedMessagesFor(
|
|
366
|
+
connect: LspConformanceOptions['connect'],
|
|
367
|
+
model: ConformanceModel,
|
|
368
|
+
params?: LspConformanceInitializeParams
|
|
369
|
+
): Promise<string[]> {
|
|
370
|
+
const driver = await connect();
|
|
371
|
+
try {
|
|
372
|
+
await driver.initialize(params);
|
|
373
|
+
const resolved = resolveModel(model);
|
|
374
|
+
const diagnostics = driver.nextDiagnostics(resolved.uri);
|
|
375
|
+
driver.openDocument(resolved.uri, resolved.text, resolved.languageId);
|
|
376
|
+
return (await diagnostics).map(diagnostic =>
|
|
377
|
+
typeof diagnostic.message === 'string' ? diagnostic.message : (diagnostic.message?.value ?? '')
|
|
378
|
+
);
|
|
379
|
+
} finally {
|
|
380
|
+
driver.dispose();
|
|
381
|
+
}
|
|
382
|
+
}
|
package/src/model.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* SPDX-License-Identifier: MIT
|
|
8
8
|
********************************************************************************/
|
|
9
9
|
|
|
10
|
+
import type { Locale } from '@hydranium/protocol';
|
|
11
|
+
|
|
10
12
|
/**
|
|
11
13
|
* A fixture value that may be given directly or DEFERRED to check time.
|
|
12
14
|
*
|
|
@@ -82,6 +84,46 @@ export interface EditSpec {
|
|
|
82
84
|
readonly expect: (root: unknown) => boolean;
|
|
83
85
|
}
|
|
84
86
|
|
|
87
|
+
/**
|
|
88
|
+
* A reference-picker query for an element that does not exist yet, plus the
|
|
89
|
+
* candidate the adopter expects it to offer.
|
|
90
|
+
*
|
|
91
|
+
* **The URI a create-element flow holds is a FOLDER**, because the file is not
|
|
92
|
+
* written until the dialog is confirmed. A folder URI names no file and so
|
|
93
|
+
* carries no extension, which is the one shape a head cannot route to a grammar
|
|
94
|
+
* by URI alone — it has to resolve the language some other way. That makes this
|
|
95
|
+
* the create dialog's load-bearing precondition and the reason the query is
|
|
96
|
+
* worth a conformance check of its own: a head that gets it wrong answers no
|
|
97
|
+
* candidates or throws, and the dialog never opens.
|
|
98
|
+
*/
|
|
99
|
+
export interface ReferenceQuerySpec {
|
|
100
|
+
/** AST type of the element being created — the synthetic source's own type. */
|
|
101
|
+
readonly type: string;
|
|
102
|
+
/** The reference property on the source (or on `syntheticPath`'s leaf) whose candidates the picker fills. */
|
|
103
|
+
readonly property: string;
|
|
104
|
+
/**
|
|
105
|
+
* Steps from the synthetic source down to the node holding `property`, when
|
|
106
|
+
* the reference is not on the source itself. Each step is
|
|
107
|
+
* `[containerProperty, type]` — the kit builds the `SyntheticStep`s, so the
|
|
108
|
+
* fixture names no protocol type.
|
|
109
|
+
*/
|
|
110
|
+
readonly path?: ReadonlyArray<readonly [containerProperty: string, type: string]>;
|
|
111
|
+
/**
|
|
112
|
+
* Folder the create flow asks at. {@link Deferred} because a fixture may name
|
|
113
|
+
* a workspace the driver's `connect` only just created. Defaults to the parent
|
|
114
|
+
* of `valid.uri`, which is the folder a sibling of the valid model would go
|
|
115
|
+
* into — the common case, so most fixtures supply only `type` + `property`.
|
|
116
|
+
*/
|
|
117
|
+
readonly folderUri?: Deferred<string>;
|
|
118
|
+
/**
|
|
119
|
+
* A candidate label the query MUST offer. Without it an empty result passes,
|
|
120
|
+
* and empty is exactly what the defect this check exists for produces — so
|
|
121
|
+
* the expectation is what makes the check discriminating rather than a
|
|
122
|
+
* smoke test.
|
|
123
|
+
*/
|
|
124
|
+
readonly expectCandidate: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
85
127
|
/**
|
|
86
128
|
* The per-language fixture. `valid` and `invalid` are defined once and reused
|
|
87
129
|
* across heads; the two extras are per-head opt-ins.
|
|
@@ -98,6 +140,9 @@ export interface EditSpec {
|
|
|
98
140
|
* uses `invalid.text` and never calls `edit.expect`, so an LSP-only adopter
|
|
99
141
|
* has nothing to supply here.
|
|
100
142
|
* - `completionPosition` — read by the **LSP slice only**.
|
|
143
|
+
* - `referenceQuery` — read by the **data slice only**, and only when the
|
|
144
|
+
* driver supplies `references` (the reference surface is opt-in on the head
|
|
145
|
+
* too, so both halves have to be present for the check to run).
|
|
101
146
|
*
|
|
102
147
|
* Both extras are optional and their checks report *skipped* when absent,
|
|
103
148
|
* rather than silently not running. Making either mandatory would defeat the
|
|
@@ -119,4 +164,67 @@ export interface LanguageFixture {
|
|
|
119
164
|
readonly edit?: EditSpec;
|
|
120
165
|
/** Optional: a position at which the LSP completion check requests completion. */
|
|
121
166
|
readonly completionPosition?: { readonly line: number; readonly character: number };
|
|
167
|
+
/**
|
|
168
|
+
* Optional: the create-dialog reference query. Read by the **data slice
|
|
169
|
+
* only**, and only when the driver exposes the opt-in reference surface.
|
|
170
|
+
*/
|
|
171
|
+
readonly referenceQuery?: ReferenceQuerySpec;
|
|
172
|
+
/**
|
|
173
|
+
* Optional: a second document that REFERENCES {@link valid}, so the data
|
|
174
|
+
* slice can provoke a CASCADE — a rebuild of this document caused by
|
|
175
|
+
* editing the one it points at, with its own text never touched.
|
|
176
|
+
*
|
|
177
|
+
* Opt-in because a grammar need not have cross-document references at all,
|
|
178
|
+
* and because only the adopter knows which pair of documents forms one.
|
|
179
|
+
* Supplying it IS the claim that editing `valid` rebuilds this document; the
|
|
180
|
+
* check then holds the head to reporting that on `onDocumentsBuilt`, which
|
|
181
|
+
* is the only channel that can carry it — the document has no subscriber and
|
|
182
|
+
* its file did not change, so neither the update channel nor a filesystem
|
|
183
|
+
* watcher can.
|
|
184
|
+
*/
|
|
185
|
+
readonly dependent?: ConformanceModel;
|
|
186
|
+
/**
|
|
187
|
+
* Optional: a locale plus the sentence the server must publish in it. Read
|
|
188
|
+
* by the **LSP slice only**.
|
|
189
|
+
*/
|
|
190
|
+
readonly renderedDiagnostic?: RenderedDiagnosticSpec;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* A locale, and one sentence the server must produce in it for the `invalid`
|
|
195
|
+
* fixture.
|
|
196
|
+
*
|
|
197
|
+
* **Opt-in, and it has to be.** The framework ships no catalogue and selects no
|
|
198
|
+
* locale, so a server that installs no renderer correctly publishes English —
|
|
199
|
+
* mandating this check would fail every adopter without i18n for doing the right
|
|
200
|
+
* thing. Supplying the field is the adopter saying "I render server-side, hold me
|
|
201
|
+
* to it".
|
|
202
|
+
*
|
|
203
|
+
* `expected` is a SUBSTRING, not the whole message. The kit owns no grammar, so
|
|
204
|
+
* it cannot know how many diagnostics `invalid` produces or in what order, and
|
|
205
|
+
* an adopter should be able to pin the translated fragment without restating a
|
|
206
|
+
* sentence they may reword. A substring long enough to be wrong if the render
|
|
207
|
+
* did not happen is the whole requirement.
|
|
208
|
+
*
|
|
209
|
+
* `absentWithoutLocale` is what makes the check a pair rather than a single
|
|
210
|
+
* assertion: "the message contains X" also passes for a server whose English
|
|
211
|
+
* happens to contain X, and for one that renders regardless of locale. Naming
|
|
212
|
+
* the fragment that must DISAPPEAR when no locale is declared is what
|
|
213
|
+
* distinguishes those.
|
|
214
|
+
*/
|
|
215
|
+
export interface RenderedDiagnosticSpec {
|
|
216
|
+
/** The locale to declare at `initialize` — the tag whose catalogue the server has. */
|
|
217
|
+
readonly locale: Locale;
|
|
218
|
+
/** A fragment of the translated sentence, present in some diagnostic of the `invalid` fixture. */
|
|
219
|
+
readonly expected: string;
|
|
220
|
+
/**
|
|
221
|
+
* A fragment that must be absent once `locale` is declared, and present
|
|
222
|
+
* without it — normally a piece of the server's own English.
|
|
223
|
+
*
|
|
224
|
+
* Optional only because a catalogue may translate a message whose English
|
|
225
|
+
* shares no distinctive fragment with it. Omitting it drops the second half
|
|
226
|
+
* of the pair and leaves a check that a render-nothing server can pass; the
|
|
227
|
+
* kit reports that rather than pretending otherwise.
|
|
228
|
+
*/
|
|
229
|
+
readonly absentWithLocale?: string;
|
|
122
230
|
}
|