@pieceful/ravel-language-service 0.2.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 James Taylor
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,180 @@
1
+ # `@pieceful/ravel-language-service`
2
+
3
+ Editor-neutral routing between authored [Ravel](https://github.com/jostylr/ravel) locations, virtual projections,
4
+ and native target-language bridges. It also classifies generated workspace
5
+ edits before an editor host is allowed to modify authored documents.
6
+
7
+ The package uses absolute UTF-16 offsets at its portable boundary. An editor or
8
+ LSP host converts line/character positions with the projection line indexes.
9
+ It does not execute Ravel effects and never writes source, deliverables, or
10
+ shadow files.
11
+
12
+ ## Request routing
13
+
14
+ ```js
15
+ import { createLanguageRouter } from "@pieceful/ravel-language-service";
16
+
17
+ const router = createLanguageRouter({
18
+ projectionService,
19
+ bridges: [typescriptBridge],
20
+ trace: (event) => telemetry.event(event),
21
+ });
22
+
23
+ await router.update(snapshot, abortSignal);
24
+
25
+ const response = await router.request(
26
+ "completion",
27
+ { uri: "guide.md", offset: 120 },
28
+ {
29
+ targetId: "web",
30
+ artifactId: "dist/app.ts",
31
+ request: { options: { includeCompletionsForModuleExports: true } },
32
+ },
33
+ abortSignal,
34
+ );
35
+
36
+ if (response.status === "ok") {
37
+ showCompletions(response.result.items);
38
+ }
39
+ ```
40
+
41
+ For a selected source location the router:
42
+
43
+ 1. finds every matching projection occurrence;
44
+ 2. selects a target, artifact, stage, and optional occurrence;
45
+ 3. synchronizes all same-target, same-stage documents supported by the bridge;
46
+ 4. sends only the generated-document contract to the bridge; and
47
+ 5. maps locations and ranges back to authored source while retaining generated
48
+ context on the normalized result.
49
+
50
+ The bridge never receives provenance maps, authored source text, source line
51
+ indexes, or source versions.
52
+
53
+ ### Ambiguity is a host decision
54
+
55
+ If a source position occurs in multiple targets and neither `targetId` nor a
56
+ matching `defaultTargetId` selects one, the response is
57
+ `{ status: "target-required", candidates }`. Multiple artifacts in the chosen
58
+ target also return `target-required` until the host supplies `artifactId`;
59
+ there is no silent first-artifact fallback. Use `listTargets()` to populate a
60
+ picker and persist only a deliberate host/user choice. `stage` and
61
+ `occurrenceId` can further pin the generated context.
62
+
63
+ Several exact occurrences remaining in one target and artifact also return
64
+ `target-required`, with `ambiguityKind: "occurrence"`, until the host supplies
65
+ the selected `projectionId`/`occurrenceId`. Duplicate mapping candidates are
66
+ collapsed only when their routing context is identical; generated text
67
+ equality is not treated as semantic equivalence.
68
+
69
+ Other non-throwing statuses are `unmapped`, `exact-mapping-required`,
70
+ `bridge-unavailable`, and `capability-unavailable`. Completion, completion
71
+ details, signature help, prepare-rename, and rename require an exact cursor
72
+ mapping. Navigation requests can retain coarser generated context.
73
+
74
+ ### Cancellation, versions, and failures
75
+
76
+ Pass an `AbortSignal` through both `update()` and `request()`. The router does
77
+ not publish a superseded projection update, forwards cancellation to bridge
78
+ open/change/request work, and checks cancellation again before returning.
79
+ After a native response, it verifies that the selected projection version is
80
+ still current. A stale response throws retryable `BRIDGE_STALE_DOCUMENT`
81
+ instead of being mapped into the new snapshot.
82
+
83
+ The current router serializes each complete request against projection updates:
84
+ candidate selection, compatible-document synchronization, native execution,
85
+ stale validation, and reverse mapping share one consistency boundary.
86
+ Per-bridge/per-document queues prevent duplicate or out-of-order
87
+ open/change/close operations. A queued caller can still observe cancellation
88
+ promptly; its already-enqueued operation stays ordered and checks the same
89
+ signal before doing work.
90
+
91
+ A retryable bridge error is retried once by default when the bridge implements
92
+ `restart()`; set `maximumRetries` in request options to change that bound.
93
+ Cancellation is never treated as a crash and never restarts the bridge.
94
+ Capability and routing outcomes use the statuses above. Lifecycle, native, and
95
+ stale failures throw structured `LanguageBridgeError` values for the host to
96
+ surface or suppress deliberately.
97
+
98
+ Call `dispose()` to dispose registered bridges and forget synchronized virtual
99
+ documents. `registerBridge()` returns an unregister function for dynamic host
100
+ integrations.
101
+
102
+ ## Safe workspace-edit policy
103
+
104
+ Native rename and completion code actions are never directly applicable.
105
+ `router.request()` attaches `classifiedEdit` to those responses, or a host can
106
+ call `classifyWorkspaceEdit()` directly:
107
+
108
+ ```js
109
+ import {
110
+ classifyWorkspaceEdit,
111
+ validateSourceEditVersions,
112
+ } from "@pieceful/ravel-language-service";
113
+
114
+ const classified = classifyWorkspaceEdit(generatedWorkspaceEdit, {
115
+ projectionService,
116
+ sourceVersions: new Map([["guide.md", 17]]),
117
+ isWritableSource(uri) {
118
+ // Canonicalize the URI, resolve symlinks, and check an explicit workspace
119
+ // allowlist here. A string-prefix check is not sufficient.
120
+ return writableSourceUris.has(canonicalSourceUri(uri));
121
+ },
122
+ limits: {
123
+ documents: 32,
124
+ edits: 2_000,
125
+ replacementTextCodeUnits: 250_000,
126
+ },
127
+ });
128
+
129
+ if (classified.applicable) {
130
+ const versions = currentOpenDocumentVersions();
131
+ const checked = validateSourceEditVersions(classified.sourceEdit, versions);
132
+ if (checked.valid) applyAtomically(classified.sourceEdit);
133
+ }
134
+ ```
135
+
136
+ Automatic application is fail-closed. Every edit must:
137
+
138
+ - target a currently known virtual document and, when supplied, its current
139
+ projection version;
140
+ - map to exactly one `exact`/`identity` authored range;
141
+ - have both a writable mapping and a projection stage that allows writable
142
+ edits;
143
+ - pass the host's `isWritableSource(uri)` allowlist; and
144
+ - carry a known, non-negative current authored version from `sourceVersions`.
145
+
146
+ Completion replacement spans and prepare-rename ranges are reverse-mapped only
147
+ through the projection occurrence selected for the request. The router exposes
148
+ an authored primary range only when that context produces one unique writable
149
+ `exact`/`identity` destination; zero or several destinations remain
150
+ generated-only, so a host cannot accidentally apply a plausible range from a
151
+ sibling expansion.
152
+
153
+ The callback is the security boundary for write scope. It should compare
154
+ canonical paths/URIs against explicit workspace roots and account for symlinks,
155
+ case rules, and URI encoding. Omitting either the callback or a source version
156
+ rejects the edit. Immediately before applying, call
157
+ `validateSourceEditVersions()` with freshly read versions and apply all
158
+ documents atomically; a missing or changed version makes validation fail.
159
+
160
+ Classification outcomes are:
161
+
162
+ | Classification | Meaning | Host behavior |
163
+ | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
164
+ | `automatic` | Every entry passed all exactness, writability, version, conflict, and size checks. | May apply only when `applicable === true` and final version validation succeeds. |
165
+ | `preview` | Provenance exists but is ambiguous, anchored, transformed, or otherwise non-exact. | Show context/preview; do not apply the returned partial edit set. |
166
+ | `action` | Synthetic text can be routed only through an explicit `importDestination` policy. | Offer a separate, host-defined action; do not treat it as a text edit. |
167
+ | `rejected` | The proposal is stale, opaque, synthetic without policy, unmapped, outside the allowlist, conflicting, malformed, oversized, or contains a resource operation. | Do not apply. Surface a safe reason when useful. |
168
+
169
+ The overall classification is the strictest entry. Even if `sourceEdit`
170
+ contains individually exact entries, never partially apply it when the overall
171
+ classification is not `automatic`. Identical edits caused by repeated
172
+ expansion are deduplicated; overlapping non-identical authored edits are
173
+ rejected. Generated create/rename/delete operations are always rejected.
174
+
175
+ Default limits are 128 documents, 5,000 edits, and 1,000,000 replacement-text
176
+ UTF-16 code units. Hosts can lower these positive limits. Limits bound proposal
177
+ classification, not native service execution, so adapters should enforce their
178
+ own resource budgets too.
179
+
180
+ MIT © James Taylor
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@pieceful/ravel-language-service",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "description": "Source-mapped language request routing and safe edit classification for Ravel.",
6
+ "license": "MIT",
7
+ "author": "James Taylor",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/jostylr/ravel.git",
11
+ "directory": "packages/language-service"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/jostylr/ravel/issues"
15
+ },
16
+ "homepage": "https://ravel.jostylr.com/",
17
+ "engines": {
18
+ "node": ">=22"
19
+ },
20
+ "keywords": [
21
+ "ravel",
22
+ "literate-programming",
23
+ "language-service",
24
+ "lsp",
25
+ "source-map"
26
+ ],
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "exports": {
31
+ ".": "./src/index.js",
32
+ "./edits": {
33
+ "types": "./src/edits.d.ts",
34
+ "default": "./src/edits.js"
35
+ }
36
+ },
37
+ "types": "./src/index.d.ts",
38
+ "files": [
39
+ "src",
40
+ "README.md",
41
+ "LICENSE"
42
+ ],
43
+ "dependencies": {
44
+ "@pieceful/ravel-core": "0.2.0",
45
+ "@pieceful/ravel-language-bridge": "0.2.0",
46
+ "@pieceful/ravel-projection": "0.2.0"
47
+ }
48
+ }
package/src/edits.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ export interface OffsetRange { start: number; end: number; }
2
+ export interface SourceTextEdit { range: OffsetRange; text: string; }
3
+ export interface SourceDocumentEdit {
4
+ uri: string;
5
+ version?: number;
6
+ edits: SourceTextEdit[];
7
+ }
8
+ export type EditClassification = "automatic" | "preview" | "action" | "rejected";
9
+ export interface ClassifiedWorkspaceEdit {
10
+ classification: EditClassification;
11
+ applicable: boolean;
12
+ entries: Array<Record<string, unknown>>;
13
+ sourceEdit: { documents: SourceDocumentEdit[] };
14
+ }
15
+ export function classifyWorkspaceEdit(workspaceEdit: unknown, options: {
16
+ projectionService: Record<string, unknown>;
17
+ sourceVersions?: Map<string, number> | Record<string, number>;
18
+ isWritableSource?: (uri: string) => boolean;
19
+ importDestination?: unknown;
20
+ limits?: {
21
+ documents?: number;
22
+ edits?: number;
23
+ replacementTextCodeUnits?: number;
24
+ };
25
+ }): ClassifiedWorkspaceEdit;
26
+ export function validateSourceEditVersions(
27
+ sourceEdit: { documents?: SourceDocumentEdit[] },
28
+ currentVersions: Map<string, number> | Record<string, number>
29
+ ): { valid: boolean; stale: Array<{ uri: string; expected: number; actual?: number }> };
package/src/edits.js ADDED
@@ -0,0 +1,397 @@
1
+ const classificationRank = Object.freeze({
2
+ automatic: 0,
3
+ preview: 1,
4
+ action: 2,
5
+ rejected: 3
6
+ });
7
+
8
+ const rangeValue = (value) => {
9
+ if (Number.isInteger(value)) return value;
10
+ if (Number.isInteger(value?.offset)) return value.offset;
11
+ return undefined;
12
+ };
13
+
14
+ const normalizeRange = (range) => {
15
+ const start = rangeValue(range?.start);
16
+ const end = rangeValue(range?.end);
17
+ return Number.isInteger(start) && Number.isInteger(end) && start >= 0 && end >= start
18
+ ? { start, end }
19
+ : null;
20
+ };
21
+
22
+ const normalizedTextEdit = (edit) => {
23
+ const range = normalizeRange(edit?.range);
24
+ const text = edit?.newText ?? edit?.text;
25
+ if (!range || typeof text !== "string") return null;
26
+ return { range, text };
27
+ };
28
+
29
+ const documentEdits = (workspaceEdit) => {
30
+ const documents = [];
31
+ if (workspaceEdit?.changes && !Array.isArray(workspaceEdit.changes)) {
32
+ for (const [uri, edits] of Object.entries(workspaceEdit.changes)) {
33
+ documents.push({ uri, edits });
34
+ }
35
+ }
36
+ for (const change of workspaceEdit?.documentChanges ?? workspaceEdit?.documents ?? []) {
37
+ if (change?.kind === "create" || change?.kind === "rename" || change?.kind === "delete") {
38
+ documents.push({ resourceOperation: change });
39
+ continue;
40
+ }
41
+ documents.push({
42
+ uri: change?.textDocument?.uri ?? change?.uri,
43
+ version: change?.textDocument?.version ?? change?.version,
44
+ edits: change?.edits
45
+ });
46
+ }
47
+ return documents;
48
+ };
49
+
50
+ const projectionForUri = (service, uri) => {
51
+ if (typeof service?.getProjectionByUri === "function") {
52
+ return service.getProjectionByUri(uri);
53
+ }
54
+ if (typeof service?.listProjections === "function") {
55
+ return service.listProjections().find((entry) => entry.uri === uri);
56
+ }
57
+ if (service?.projections instanceof Map) {
58
+ return [...service.projections.values()].find((entry) => entry.uri === uri);
59
+ }
60
+ return undefined;
61
+ };
62
+
63
+ const sourceFromMatch = (match) => {
64
+ const source = match?.source ?? match?.sourceRange ?? match;
65
+ const uri = source?.uri ?? match?.uri;
66
+ const range = normalizeRange(source?.range ?? source);
67
+ return typeof uri === "string" && range ? { uri, range } : null;
68
+ };
69
+
70
+ const mappingKind = (match) =>
71
+ match?.kind ?? match?.mappingKind ?? match?.quality ?? match?.precision;
72
+
73
+ const exactMapping = (match) => ["exact", "identity"].includes(mappingKind(match));
74
+
75
+ const sourceVersionFor = (sourceVersions, uri) =>
76
+ sourceVersions?.get?.(uri) ?? sourceVersions?.[uri];
77
+
78
+ const classifyOne = (service, projection, edit, options) => {
79
+ const mapped = typeof service?.toSource === "function"
80
+ ? service.toSource(projection.id, edit.range)
81
+ : [];
82
+ const matches = Array.isArray(mapped) ? mapped : mapped ? [mapped] : [];
83
+ const exact = matches.filter((match) => exactMapping(match) && sourceFromMatch(match));
84
+
85
+ if (exact.length === 1) {
86
+ const source = sourceFromMatch(exact[0]);
87
+ const sourceEdit = { ...source, text: edit.text };
88
+ if (exact[0].writable === false || projection.capabilities?.writableEdits === false) {
89
+ return {
90
+ classification: "rejected",
91
+ reason: "non-writable-mapping",
92
+ message: "The exact mapping is not declared writable at this projection stage.",
93
+ projection,
94
+ virtualEdit: edit,
95
+ sourceEdit
96
+ };
97
+ }
98
+ if (typeof options.isWritableSource !== "function") {
99
+ return {
100
+ classification: "rejected",
101
+ reason: "writability-unverified",
102
+ message: "The host did not prove that the mapped source is writable.",
103
+ projection,
104
+ virtualEdit: edit,
105
+ sourceEdit
106
+ };
107
+ }
108
+ if (!options.isWritableSource(source.uri)) {
109
+ return {
110
+ classification: "rejected",
111
+ reason: "outside-workspace",
112
+ message: "The mapped source is outside the writable workspace.",
113
+ projection,
114
+ virtualEdit: edit,
115
+ sourceEdit
116
+ };
117
+ }
118
+ const version = sourceVersionFor(options.sourceVersions, source.uri);
119
+ if (!Number.isInteger(version) || version < 0) {
120
+ return {
121
+ classification: "rejected",
122
+ reason: "source-version-unavailable",
123
+ message: "The host cannot prove that the mapped source version is current.",
124
+ projection,
125
+ virtualEdit: edit,
126
+ sourceEdit
127
+ };
128
+ }
129
+ const projectedVersion = sourceVersionFor(projection.sourceVersions, source.uri);
130
+ if (!Number.isInteger(projectedVersion) || projectedVersion < 0 ||
131
+ projectedVersion !== version) {
132
+ return {
133
+ classification: "rejected",
134
+ reason: "projection-source-version-mismatch",
135
+ message: "The projection was not built from the host-current source version.",
136
+ projection,
137
+ virtualEdit: edit,
138
+ sourceEdit,
139
+ expectedVersion: projectedVersion,
140
+ actualVersion: version
141
+ };
142
+ }
143
+ return {
144
+ classification: "automatic",
145
+ projection,
146
+ virtualEdit: edit,
147
+ sourceEdit
148
+ };
149
+ }
150
+
151
+ if (exact.length > 1) {
152
+ return {
153
+ classification: "preview",
154
+ reason: "ambiguous-source",
155
+ message: "The generated edit maps exactly to more than one authored range.",
156
+ projection,
157
+ virtualEdit: edit,
158
+ matches
159
+ };
160
+ }
161
+
162
+ const qualities = new Set(matches.map(mappingKind).filter(Boolean));
163
+ if (qualities.has("synthetic") && options.importDestination) {
164
+ return {
165
+ classification: "action",
166
+ reason: "import-destination",
167
+ message: "The generated edit needs a declared imports or preamble destination.",
168
+ projection,
169
+ virtualEdit: edit,
170
+ action: {
171
+ kind: "route-import",
172
+ destination: options.importDestination,
173
+ text: edit.text
174
+ }
175
+ };
176
+ }
177
+ if (qualities.has("anchored") || qualities.has("transformed") ||
178
+ qualities.has("coarse")) {
179
+ return {
180
+ classification: "preview",
181
+ reason: "non-exact-mapping",
182
+ message: "The generated edit has provenance but no exact reversible character mapping.",
183
+ projection,
184
+ virtualEdit: edit,
185
+ matches
186
+ };
187
+ }
188
+ return {
189
+ classification: "rejected",
190
+ reason: qualities.has("opaque")
191
+ ? "opaque-transform"
192
+ : qualities.has("synthetic")
193
+ ? "synthetic-text"
194
+ : "unmapped-text",
195
+ message: qualities.has("opaque")
196
+ ? "The generated edit crosses an opaque transform."
197
+ : qualities.has("synthetic")
198
+ ? "Synthetic generated text has no authored destination."
199
+ : "The generated edit has no authored destination.",
200
+ projection,
201
+ virtualEdit: edit,
202
+ matches
203
+ };
204
+ };
205
+
206
+ const overlaps = (left, right) =>
207
+ left.range.start < right.range.end && right.range.start < left.range.end ||
208
+ left.range.start === left.range.end && right.range.start === right.range.end &&
209
+ left.range.start === right.range.start;
210
+
211
+ const classifyConflicts = (entries) => {
212
+ const byUri = new Map();
213
+ for (const entry of entries) {
214
+ if (!entry.sourceEdit) continue;
215
+ const list = byUri.get(entry.sourceEdit.uri) ?? [];
216
+ list.push(entry);
217
+ byUri.set(entry.sourceEdit.uri, list);
218
+ }
219
+ for (const list of byUri.values()) {
220
+ list.sort((left, right) =>
221
+ left.sourceEdit.range.start - right.sourceEdit.range.start ||
222
+ left.sourceEdit.range.end - right.sourceEdit.range.end
223
+ );
224
+ for (let index = 1; index < list.length; index += 1) {
225
+ const previous = list[index - 1];
226
+ const current = list[index];
227
+ if (!overlaps(previous.sourceEdit, current.sourceEdit)) continue;
228
+ const identical = previous.sourceEdit.range.start === current.sourceEdit.range.start &&
229
+ previous.sourceEdit.range.end === current.sourceEdit.range.end &&
230
+ previous.sourceEdit.text === current.sourceEdit.text;
231
+ if (identical) {
232
+ current.duplicate = true;
233
+ continue;
234
+ }
235
+ for (const entry of [previous, current]) {
236
+ entry.classification = "rejected";
237
+ entry.reason = "conflicting-source-edits";
238
+ entry.message = "Generated edits request conflicting changes to one authored range.";
239
+ }
240
+ }
241
+ }
242
+ };
243
+
244
+ const sourceDocuments = (entries, sourceVersions) => {
245
+ const documents = new Map();
246
+ for (const entry of entries) {
247
+ if (!entry.sourceEdit || entry.duplicate || entry.classification === "rejected") continue;
248
+ const { uri, range, text } = entry.sourceEdit;
249
+ const document = documents.get(uri) ?? {
250
+ uri,
251
+ version: sourceVersions?.get?.(uri) ?? sourceVersions?.[uri],
252
+ edits: []
253
+ };
254
+ document.edits.push({ range, text });
255
+ documents.set(uri, document);
256
+ }
257
+ for (const document of documents.values()) {
258
+ document.edits.sort((left, right) =>
259
+ right.range.start - left.range.start || right.range.end - left.range.end
260
+ );
261
+ }
262
+ return [...documents.values()].sort((left, right) => left.uri.localeCompare(right.uri));
263
+ };
264
+
265
+ const positiveLimit = (value, fallback) =>
266
+ Number.isInteger(value) && value > 0 ? value : fallback;
267
+
268
+ const rejectedWorkspaceEdit = (reason, message, details = {}) => ({
269
+ classification: "rejected",
270
+ applicable: false,
271
+ entries: [{ classification: "rejected", reason, message, ...details }],
272
+ sourceEdit: { documents: [] }
273
+ });
274
+
275
+ export const classifyWorkspaceEdit = (workspaceEdit, options = {}) => {
276
+ const service = options.projectionService;
277
+ if (!service) throw new TypeError("classifyWorkspaceEdit requires a projectionService.");
278
+ const limits = {
279
+ documents: positiveLimit(options.limits?.documents, 128),
280
+ edits: positiveLimit(options.limits?.edits, 5_000),
281
+ replacementTextCodeUnits: positiveLimit(
282
+ options.limits?.replacementTextCodeUnits,
283
+ 1_000_000
284
+ )
285
+ };
286
+ const documents = documentEdits(workspaceEdit);
287
+ if (documents.length > limits.documents) {
288
+ return rejectedWorkspaceEdit(
289
+ "edit-limit-exceeded",
290
+ "The generated language tool returned too many edited documents.",
291
+ { limit: limits.documents, actual: documents.length }
292
+ );
293
+ }
294
+ let editCount = 0;
295
+ let replacementTextCodeUnits = 0;
296
+ for (const document of documents) {
297
+ if (document.resourceOperation) continue;
298
+ if (!Array.isArray(document.edits)) {
299
+ return rejectedWorkspaceEdit(
300
+ "invalid-edit",
301
+ "The generated language tool returned an invalid edit collection."
302
+ );
303
+ }
304
+ editCount += document.edits.length;
305
+ for (const edit of document.edits) {
306
+ const text = edit?.newText ?? edit?.text;
307
+ if (typeof text === "string") replacementTextCodeUnits += text.length;
308
+ }
309
+ if (editCount > limits.edits ||
310
+ replacementTextCodeUnits > limits.replacementTextCodeUnits) break;
311
+ }
312
+ if (editCount > limits.edits ||
313
+ replacementTextCodeUnits > limits.replacementTextCodeUnits) {
314
+ return rejectedWorkspaceEdit(
315
+ "edit-limit-exceeded",
316
+ "The generated language tool returned an oversized workspace edit.",
317
+ {
318
+ editLimit: limits.edits,
319
+ editCount,
320
+ replacementTextCodeUnitLimit: limits.replacementTextCodeUnits,
321
+ replacementTextCodeUnits
322
+ }
323
+ );
324
+ }
325
+ const entries = [];
326
+ for (const document of documents) {
327
+ if (document.resourceOperation) {
328
+ entries.push({
329
+ classification: "rejected",
330
+ reason: "resource-operation",
331
+ message: "Generated language tools cannot directly create, rename, or delete authored files.",
332
+ resourceOperation: document.resourceOperation
333
+ });
334
+ continue;
335
+ }
336
+ const projection = projectionForUri(service, document.uri);
337
+ if (!projection) {
338
+ entries.push({
339
+ classification: "rejected",
340
+ reason: "unknown-virtual-document",
341
+ message: "The edit targets an unknown or closed virtual document.",
342
+ uri: document.uri
343
+ });
344
+ continue;
345
+ }
346
+ if (document.version !== undefined && document.version !== null &&
347
+ document.version !== projection.version) {
348
+ entries.push({
349
+ classification: "rejected",
350
+ reason: "stale-projection",
351
+ message: "The edit targets a stale projection version.",
352
+ projection,
353
+ expectedVersion: projection.version,
354
+ actualVersion: document.version
355
+ });
356
+ continue;
357
+ }
358
+ for (const supplied of document.edits) {
359
+ const edit = normalizedTextEdit(supplied);
360
+ entries.push(edit
361
+ ? classifyOne(service, projection, edit, options)
362
+ : {
363
+ classification: "rejected",
364
+ reason: "invalid-edit",
365
+ message: "The generated language tool returned an invalid text edit.",
366
+ projection,
367
+ virtualEdit: supplied
368
+ });
369
+ }
370
+ }
371
+ classifyConflicts(entries);
372
+ const classification = entries.reduce((highest, entry) =>
373
+ classificationRank[entry.classification] > classificationRank[highest]
374
+ ? entry.classification
375
+ : highest,
376
+ "automatic");
377
+ return {
378
+ classification,
379
+ applicable: classification === "automatic",
380
+ entries,
381
+ sourceEdit: {
382
+ documents: sourceDocuments(entries, options.sourceVersions)
383
+ }
384
+ };
385
+ };
386
+
387
+ export const validateSourceEditVersions = (sourceEdit, currentVersions) => {
388
+ const stale = [];
389
+ for (const document of sourceEdit?.documents ?? []) {
390
+ const current = currentVersions?.get?.(document.uri) ?? currentVersions?.[document.uri];
391
+ if (!Number.isInteger(document.version) || document.version < 0 ||
392
+ current !== document.version) {
393
+ stale.push({ uri: document.uri, expected: document.version, actual: current });
394
+ }
395
+ }
396
+ return { valid: stale.length === 0, stale };
397
+ };