@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 +21 -0
- package/README.md +180 -0
- package/package.json +48 -0
- package/src/edits.d.ts +29 -0
- package/src/edits.js +397 -0
- package/src/index.d.ts +48 -0
- package/src/index.js +881 -0
- package/src/ravel-index.d.ts +30 -0
- package/src/ravel-index.js +252 -0
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
|
+
};
|