From 4e7b6b721d2729dc1141069e20ff8b30a937bd09 Mon Sep 17 00:00:00 2001 From: hkimw <54717101+hkimw@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:57:20 +0900 Subject: [PATCH 1/4] feat: add bounded signature and completion assistance for 0.11 --- CHANGELOG.md | 11 + PUBLIC_SURFACE.json | 14 + README.md | 6 +- docs/COMPATIBILITY.md | 9 + docs/LANGUAGE.md | 11 +- docs/LANGUAGE_ASSISTANCE.md | 114 ++++++++ examples/language-assistance/LICENSE | 202 +++++++++++++ examples/language-assistance/plugin.mjs | 55 ++++ examples/language-assistance/run.mjs | 29 ++ package-lock.json | 4 +- package.json | 5 +- .../artifact-sink-capabilities.schema.json | 2 +- .../artifact-storage-capabilities.schema.json | 2 +- schemas/artifact-transfer-receipt.schema.json | 2 +- .../binary-artifact-capabilities.schema.json | 2 +- schemas/binary-artifact-chunk.schema.json | 2 +- schemas/binary-artifact-list.schema.json | 2 +- schemas/binary-artifact-reference.schema.json | 2 +- schemas/binary-artifact.schema.json | 2 +- .../browser-artifact-checkpoint.schema.json | 2 +- schemas/command.schema.json | 2 +- schemas/completion-item.schema.json | 170 +++++++++++ schemas/diagnostics-request.schema.json | 2 +- schemas/diagnostics-result.schema.json | 2 +- schemas/error.schema.json | 2 +- schemas/job-artifact-content.schema.json | 2 +- schemas/job-capabilities.schema.json | 2 +- schemas/job-events.schema.json | 2 +- schemas/job-history-page.schema.json | 2 +- schemas/job-history-query.schema.json | 2 +- schemas/job-options.schema.json | 2 +- schemas/job-recovery.schema.json | 8 +- schemas/job-snapshot.schema.json | 4 +- schemas/job-storage-capabilities.schema.json | 2 +- schemas/job-store-identity.schema.json | 2 +- schemas/json-value.schema.json | 2 +- schemas/language-capabilities.schema.json | 95 +++++++ schemas/language-request.schema.json | 241 +++++++++++++++- schemas/language-result.schema.json | 267 +++++++++++++++++- schemas/language-snippet.schema.json | 9 + schemas/manifest.schema.json | 32 ++- schemas/project-entry.schema.json | 2 +- .../project-query-capabilities.schema.json | 2 +- schemas/project-query-options.schema.json | 2 +- schemas/project-query-page.schema.json | 2 +- schemas/project-snapshot.schema.json | 2 +- .../project-watch-capabilities.schema.json | 2 +- schemas/project-watch-event.schema.json | 2 +- schemas/project-watch-options.schema.json | 2 +- schemas/signature-help.schema.json | 94 ++++++ schemas/stored-artifact-chunk.schema.json | 2 +- schemas/stored-artifact-list.schema.json | 2 +- schemas/stored-artifact-reference.schema.json | 2 +- schemas/stored-artifact.schema.json | 2 +- schemas/stored-job.schema.json | 4 +- schemas/transfer-queue-options.schema.json | 2 +- schemas/transfer-snapshot.schema.json | 2 +- schemas/upload-capabilities.schema.json | 2 +- schemas/upload-chunk.schema.json | 2 +- schemas/upload-reference.schema.json | 2 +- schemas/upload-spec.schema.json | 2 +- schemas/upload-status.schema.json | 2 +- .../workspace-conditional-file.schema.json | 2 +- .../workspace-file-capabilities.schema.json | 2 +- schemas/workspace-file-revision.schema.json | 2 +- ...workspace-project-capabilities.schema.json | 2 +- schemas/workspace-project-poll.schema.json | 2 +- scripts/pack-check.mjs | 3 + src/contracts.mjs | 29 +- src/host.mjs | 20 +- src/index.d.mts | 61 +++- src/index.mjs | 3 +- src/language-assistance-schemas.mjs | 29 ++ src/language-assistance.mjs | 149 ++++++++++ src/language-registry.mjs | 163 +++++++++++ src/language-values.mjs | 63 +++++ src/lifecycle.mjs | 12 +- src/publishing.mjs | 5 +- src/schemas.mjs | 15 +- src/text-edits.mjs | 33 +++ src/version.mjs | 3 + src/workspace-node.mjs | 3 +- src/workspace-project.mjs | 32 +-- tests/language-assistance.test.mjs | 181 ++++++++++++ tests/language.test.mjs | 2 +- tests/publishing.test.mjs | 2 +- tests/types/language-assistance.mts | 27 ++ 87 files changed, 2171 insertions(+), 140 deletions(-) create mode 100644 docs/LANGUAGE_ASSISTANCE.md create mode 100644 examples/language-assistance/LICENSE create mode 100644 examples/language-assistance/plugin.mjs create mode 100644 examples/language-assistance/run.mjs create mode 100644 schemas/completion-item.schema.json create mode 100644 schemas/language-capabilities.schema.json create mode 100644 schemas/language-snippet.schema.json create mode 100644 schemas/signature-help.schema.json create mode 100644 src/language-assistance-schemas.mjs create mode 100644 src/language-assistance.mjs create mode 100644 src/language-registry.mjs create mode 100644 src/language-values.mjs create mode 100644 src/text-edits.mjs create mode 100644 src/version.mjs create mode 100644 tests/language-assistance.test.mjs create mode 100644 tests/types/language-assistance.mts diff --git a/CHANGELOG.md b/CHANGELOG.md index 97e6b50..a639e12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 0.11.0 — Unreleased + +- Add bounded signature-help context/results, one-use completion resolve tokens, + optional snippet grammar and pure insertion previews with UTF-16/CRLF validation. +- Keep actual provider work within request/resolver limits when cancellation is + ignored; invalidate retained selections on document and provider lifecycle changes. +- Add public types, schemas, a standalone teaching-language example and packed + consumer checks. Preserve existing literal completions and protocol v1. +- Centralize SDK host/schema/package-peer version metadata and retain verification + compatibility for exact previously published 0.10 plugin package metadata. + ## 0.10.0 — 2026-10-04 - Browser artifact streaming with caller-owned file/OPFS sinks and distinct diff --git a/PUBLIC_SURFACE.json b/PUBLIC_SURFACE.json index 4c03fcc..5c9b9be 100644 --- a/PUBLIC_SURFACE.json +++ b/PUBLIC_SURFACE.json @@ -19,6 +19,7 @@ "docs/JOB_STORAGE.md", "docs/JOBS.md", "docs/LANGUAGE.md", + "docs/LANGUAGE_ASSISTANCE.md", "docs/LICENSING.md", "docs/OBSERVATION.md", "docs/performance.md", @@ -60,6 +61,9 @@ "examples/hello-theme/hello-theme.xml", "examples/hello-theme/preview.html", "examples/hello-theme/theme.json", + "examples/language-assistance/LICENSE", + "examples/language-assistance/plugin.mjs", + "examples/language-assistance/run.mjs", "examples/project-query/run.mjs", "examples/project-session/run.mjs", "examples/project-tools/browser.html", @@ -109,6 +113,7 @@ "schemas/binary-artifact.schema.json", "schemas/browser-artifact-checkpoint.schema.json", "schemas/command.schema.json", + "schemas/completion-item.schema.json", "schemas/diagnostics-request.schema.json", "schemas/diagnostics-result.schema.json", "schemas/error.schema.json", @@ -123,8 +128,10 @@ "schemas/job-storage-capabilities.schema.json", "schemas/job-store-identity.schema.json", "schemas/json-value.schema.json", + "schemas/language-capabilities.schema.json", "schemas/language-request.schema.json", "schemas/language-result.schema.json", + "schemas/language-snippet.schema.json", "schemas/manifest.schema.json", "schemas/project-entry.schema.json", "schemas/project-query-capabilities.schema.json", @@ -134,6 +141,7 @@ "schemas/project-watch-capabilities.schema.json", "schemas/project-watch-event.schema.json", "schemas/project-watch-options.schema.json", + "schemas/signature-help.schema.json", "schemas/stored-artifact-chunk.schema.json", "schemas/stored-artifact-list.schema.json", "schemas/stored-artifact-reference.schema.json", @@ -192,6 +200,10 @@ "src/job-storage.mjs", "src/jobs.d.mts", "src/jobs.mjs", + "src/language-assistance-schemas.mjs", + "src/language-assistance.mjs", + "src/language-registry.mjs", + "src/language-values.mjs", "src/lifecycle.mjs", "src/limits.mjs", "src/node-project-context.mjs", @@ -220,6 +232,7 @@ "src/stored-artifact-download-node.mjs", "src/testing.d.mts", "src/testing.mjs", + "src/text-edits.mjs", "src/themes.d.mts", "src/themes.mjs", "src/transfer-queue-contracts.mjs", @@ -237,6 +250,7 @@ "src/uploads-node.mjs", "src/uploads.d.mts", "src/uploads.mjs", + "src/version.mjs", "src/workspace-client.d.mts", "src/workspace-client.mjs", "src/workspace-identity-node.mjs", diff --git a/README.md b/README.md index a430a14..59d0909 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,10 @@ a small ESM package, TypeScript declarations and versioned contracts. Connect your own workspace and tools with the Node.js host and HTTP client. The SDK has **zero runtime dependencies**. +Development toward 0.11 adds [signature help and completion assistance](docs/LANGUAGE_ASSISTANCE.md), +including lazy item resolution, limited snippets and pure insertion previews. +Run `npm run example:assistance` for the nested-call and Unicode example. + The 0.10 line adds [browser artifact streaming](docs/BROWSER_ARTIFACTS.md) with bounded sinks, incremental whole-file hashes, explicit storage readback and main-thread/Worker examples, and [selected-file uploads](docs/UPLOADS.md) with private @@ -102,7 +106,7 @@ and the install command includes the release URL rather than an npm registry loo | Import | Use | | --- | --- | -| `@altifigence/dds-plugin-sdk` | Plugin host, diagnostics and five language providers, bounded parsers, types and lifecycle helpers | +| `@altifigence/dds-plugin-sdk` | Plugin host, diagnostics, language providers and completion assistance, bounded parsers, types and lifecycle helpers | | `@altifigence/dds-plugin-sdk/testing` | `createTestHost` for trusted local plugins | | `@altifigence/dds-plugin-sdk/schemas` | JSON Schema objects, loaded separately from the core API | | `@altifigence/dds-plugin-sdk/themes` | Validate a theme and export DDS XML without executing theme code | diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 53bd765..e92a0d4 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -1,5 +1,14 @@ # Compatibility and availability +## 0.11 development + +Optional `signature-help`, `completion-resolve` and `completion-snippets` extend the +in-process host. Existing five language features and literal completion remain +available. Older SDKs reject unknown manifest capabilities; these extensions do not +change the workspace HTTP protocol. New plugin archives declare `>=0.11.0 <0.12.0`; +verification retains the exact previously published 0.3.x through 0.10.x metadata. +See [language assistance](LANGUAGE_ASSISTANCE.md) for bounds and runtime checks. + | Surface | Supported scope | | --- | --- | | SDK runtime | Node.js 22 or 24; ESM with TypeScript declarations; no runtime dependencies | diff --git a/docs/LANGUAGE.md b/docs/LANGUAGE.md index a94c0fb..ae1a290 100644 --- a/docs/LANGUAGE.md +++ b/docs/LANGUAGE.md @@ -4,6 +4,9 @@ SDK 0.3.0 adds completion, hover, definition, references and flat document symbo Run `node examples/hello-language/run.mjs` after installing the SDK. The example implements a tiny teaching language; it is not a SystemVerilog compiler or LSP server. +SDK 0.11 adds [signature help, lazy completion resolution and opt-in snippets](LANGUAGE_ASSISTANCE.md). +The original literal contracts below continue to work without these capabilities. + Declare each feature in manifest v2 `capabilities`, and declare `document.read` and `language.provide` in `permissions`. The host must explicitly grant both. Upgrading a plugin or installing its package does not grant permission. @@ -39,7 +42,8 @@ const hover = await host.requestLanguage('hover', { | `document-symbols` | `{}` | Up to 500 `{name, kind, range, selectionRange, detail?}` symbols | Positions are zero-based lines and UTF-16 code-unit offsets, including CRLF input. -Completion insertion is literal text, not snippets or commands. Hover and detail +Completion insertion defaults to literal text. Opt-in 0.11 snippets use the bounded +grammar in the assistance guide. Hover and detail strings are plain text: render them as text, never `innerHTML` or trusted Markdown. The runtime validates current-document positions, ordered ranges and symbol selection containment. Location paths are relative workspace paths; they do not @@ -71,8 +75,9 @@ manifest v2에 해당 capability와 `document.read`, `language.provide` 권한 선언하고 호스트가 두 권한을 명시적으로 승인해야 합니다. 설치·업데이트만으로 권한이 생기지 않습니다. `examples/hello-language/run.mjs`는 다섯 기능을 실제로 실행합니다. -좌표는 0부터 시작하는 줄과 UTF-16 문자 위치입니다. 자동완성은 일반 삽입 문자열, -호버는 일반 텍스트이며 HTML·명령·스니펫으로 실행하지 않습니다. 정의·참조의 +좌표는 0부터 시작하는 줄과 UTF-16 문자 위치입니다. 자동완성은 기본적으로 일반 +삽입 문자열이며, 0.11의 스니펫은 별도 capability와 제한 문법을 사용합니다. +호버는 일반 텍스트이며 HTML·명령을 실행하지 않습니다. 정의·참조의 상대 경로는 파일 접근 권한을 부여하지 않습니다. 문서 변경, 취소, 시간 초과, 등록 해제 또는 호스트 종료 뒤의 결과는 폐기합니다. 결과의 요청 ID와 문서 버전도 확인합니다. diff --git a/docs/LANGUAGE_ASSISTANCE.md b/docs/LANGUAGE_ASSISTANCE.md new file mode 100644 index 0000000..4cfeb2b --- /dev/null +++ b/docs/LANGUAGE_ASSISTANCE.md @@ -0,0 +1,114 @@ +# Signature help and completion assistance + +SDK 0.11 adds `signature-help`, lazy completion resolution and a bounded snippet +grammar to the trusted in-process language host. The original literal completion, +hover, definition, references and document-symbol APIs remain available. + +Declare `signature-help`, `completion-resolve` and `completion-snippets` as needed +in manifest v2 `capabilities`; the last two also require `completion`. Registration +requires the existing `document.read` and `language.provide` grants. A completion +provider with a `resolve` method must declare `completion-resolve`, and a plugin +declaring that capability must supply the method. `host.languageCapabilities()` +describes the SDK implementation, without granting any plugin permission. + +## Independent example + +After installing the package, copy `examples/language-assistance` from the installed +package to your project and run `node language-assistance/run.mjs`. In this checkout, +run `npm run example:assistance`. It exercises nested calls, two signatures, CRLF, +Unicode, lazy resolution and insertion preview using only package imports. + +```js +const result = await host.requestLanguage('signature-help', { + position: {line: 0, character: 8}, + context: {triggerKind: 'character', triggerCharacter: ',', isRetrigger: false}, +}); +const list = await host.requestLanguage('completion', {position}); +const detailed = await host.resolveCompletion(list.data[0].resolveToken); +const preview = host.prepareCompletion(detailed); +// Display preview.content and preview.tabstops to the user. +host.releaseCompletion(list); +host.releaseCompletion(detailed); +``` + +`position` and `host` above come from the embedding application's current document +and explicitly configured host. Resolution is optional: a literal completion can +go directly from `requestLanguage` to `prepareCompletion`. Preparation is pure: +it returns `{snapshot, content, edits, tabstops}` and performs no document/file write. + +## Signature and trigger contracts + +`signature-help` returns `null` or `{signatures, activeSignature, activeParameter}`. +Each signature has a nonempty plain-text `label`, `parameters`, and optional plain +`documentation`. Parameter labels are nonempty `[start, end]` UTF-16 offsets into +the signature label. Active indices must exist; `activeParameter` is `null` only +when the selected signature has no parameters. Limits are 16 signatures, 64 +parameters per signature, 2,048 UTF-16 units per label and 16,384 per documentation. + +Optional signature context uses `triggerKind` of `invoked`, `character` or +`content-change`, required `isRetrigger`, and optional active indices on retriggers. +Completion context accepts `invoked`, `character` or `incomplete`. Only a `character` +trigger has a required single Unicode `triggerCharacter`; CR, LF and NUL are rejected. +The SDK carries context; the language provider decides which call/signature is active. + +## Lazy completion resolution + +A provider can return a completion item with bounded plain-JSON `resolveData` +(4 KiB). The host removes it from the visible list and substitutes a one-use +`resolveToken`. `resolve(request, originalItem, {signal})` receives the original +request and item, and returns one completion item with optional documentation, +detail and additional text edits. Label, insertion text, insertion format and the +effective main range must stay unchanged. Unknown executable fields are rejected. + +Tokens are bound to the issuing registry, provider registration, full snapshot, +scope, request and selected item. They expire after 60 seconds, and are cleared +on document changes, unregistration, plugin deactivation and host disposal. Editing +and restoring the same text does not revive a token. A resolve attempt consumes its +token even if invalid options, cancellation, timeout or capacity prevents success; +request a new list for a new attempt. The host retains at most 128 tokens and 4 MiB +of charged request/item data, with at most four actual resolver operations at once. +Expired tokens are removed on the next registry operation; there is no background +expiry timer. Release unused results explicitly to reclaim retained data sooner. + +The 64-operation limit per feature counts actual provider work, including cancelled +operations whose provider ignores its signal. Consumer cancellation settles promptly, +but does not manufacture free provider capacity. Default timeout is 5 seconds and +maximum timeout is 30 seconds. Late or stale results never become insertions. + +## Snippet subset and insertion + +Items remain literal unless `insertTextFormat: 'snippet'` is explicit and the plugin +declares `completion-snippets`. Supported syntax is `$n`, `${n}`, `${n:literal}` and +one `$0`, with indices 0..99. Escape backslash, dollar and braces with a backslash. +Mirrors share one consistent literal default, including forward references. Tab +navigation visits positive indices in ascending order, then zero; when absent, +zero is appended at the end. Variables, nested placeholders, choices and transforms +are rejected. There are at most 128 explicit stops and 16,384 UTF-16 units in both +source and expanded text. `parseSnippet` returns text and numeric UTF-16 offsets. + +`prepareCompletion` accepts only the actual result object returned by that host. +It converts snippet offsets into document ranges, accounting for up to 32 additional +edits before the insertion. All edits are relative to the original snapshot and +must have valid, non-overlapping ranges; ambiguous shared insertion points, split +surrogates/CRLF, out-of-document ranges and oversized final files are rejected. +Changing the document or releasing a result invalidates its preview. The caller +must obtain fresh authority and check revisions before separately applying edits. + +All labels, detail and documentation are display text, including strings that look +like HTML. Render as text. These APIs do not execute commands, download resources, +extend workspace HTTP protocol v1 or establish product editor support. JSON Schemas +cover structure; runtime validation additionally checks UTF-16 lengths, snippet +grammar, actual document ranges, UTF-8 budgets and identity. + +## 한국어 + +0.11은 함수 서명/인자 안내, 선택한 자동완성 항목의 지연 해석, 제한된 스니펫을 +공개 SDK 호스트에 추가합니다. manifest capability와 기존 문서 읽기/언어 제공 +권한을 명시해야 합니다. 토큰은 현재 문서와 제공자에 묶이며 60초 후 만료되고, +문서 변경·등록 해제·호스트 종료 뒤에는 사용할 수 없습니다. 취소를 무시한 제공자는 +실제 처리가 끝날 때까지 동시 실행 한도를 차지합니다. + +삽입 준비는 텍스트와 UTF-16 탭 이동 범위를 계산하는 미리보기입니다. 파일을 자동 +변경하거나 HTML·명령을 실행하지 않습니다. 실제 적용은 별도의 현재 권한·버전 +확인이 필요합니다. 예제는 중첩 호출에서 안내할 함수를 선택하고, 자동완성 선택 후 +상세 정보를 해석하여 CRLF/Unicode 문서의 삽입 미리보기를 확인합니다. diff --git a/examples/language-assistance/LICENSE b/examples/language-assistance/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/examples/language-assistance/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/examples/language-assistance/plugin.mjs b/examples/language-assistance/plugin.mjs new file mode 100644 index 0000000..fd61853 --- /dev/null +++ b/examples/language-assistance/plugin.mjs @@ -0,0 +1,55 @@ +import {definePlugin, createLanguageResult} from '@altifigence/dds-plugin-sdk'; + +// Teaching grammar: identifiers, decimal numbers, nested calls and commas. +// Real language plugins should obtain this context from their own parser. +function activeCall(text, position) { + const lines = text.split(/\r\n|\r|\n/); + const prefix = [...lines.slice(0, position.line), lines[position.line].slice(0, position.character)].join('\n'); + const calls = []; + for (const token of prefix.matchAll(/[A-Za-z_]+\s*\(|[(),]/g)) { + if (token[0].endsWith('(')) calls.push({name: token[0].slice(0, -1).trim(), parameter: 0}); + else if (token[0] === ')') calls.pop(); + else if (calls.length) calls.at(-1).parameter++; + } + return calls.at(-1); +} + +export default definePlugin({ + manifestVersion: 2, id: 'teaching-assistance', name: 'Teaching Assistance', publisher: 'example', version: '1.0.0', + protocolVersion: 1, entry: './plugin.mjs', runtime: 'ui', license: 'Apache-2.0', + source: {visibility: 'open', licenseFile: 'LICENSE'}, supportedHosts: ['test-host'], + capabilities: ['signature-help', 'completion', 'completion-resolve', 'completion-snippets'], + permissions: ['document.read', 'language.provide'], +}, context => { + context.registerLanguageProvider('signature-help', {languages: ['teaching']}, { + provide(request, {signal}) { + signal.throwIfAborted(); + const call = activeCall(request.snapshot.text, request.position); + if (!call || !['sum', 'scale'].includes(call.name)) return createLanguageResult(request, null); + const label = call.name + '(left, right)'; + const start = call.name.length + 1; + return createLanguageResult(request, { + signatures: [ + {label, parameters: [{label: [start, start + 4]}, {label: [start + 6, start + 11]}], documentation: 'Combine two numbers.'}, + {label: call.name + '(values)', parameters: [{label: [start, start + 6]}], documentation: 'Combine a list of numbers.'}, + ], activeSignature: 0, activeParameter: Math.min(call.parameter, 1), + }); + }, + }); + context.registerLanguageProvider('completion', {languages: ['teaching']}, { + provide(request, {signal}) { + signal.throwIfAborted(); + const prefix = request.snapshot.text.split(/\r\n|\r|\n/)[request.position.line].slice(0, request.position.character).match(/[A-Za-z_]*$/)[0]; + return createLanguageResult(request, [{ + label: 'sum', insertText: 'sum(${1:left}, ${2:right})$0', insertTextFormat: 'snippet', + range: {start: {...request.position, character: request.position.character - prefix.length}, end: request.position}, + resolveData: {symbol: 'sum'}, + }]); + }, + async resolve(_request, item, {signal}) { + signal.throwIfAborted(); + // Lookup happens only after the caller selects this item. No I/O is needed here. + return {...item, detail: 'sum(left, right)', documentation: 'Adds two numbers. This is plain text.'}; + }, + }); +}); diff --git a/examples/language-assistance/run.mjs b/examples/language-assistance/run.mjs new file mode 100644 index 0000000..11e48d8 --- /dev/null +++ b/examples/language-assistance/run.mjs @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import {createPluginHost} from '@altifigence/dds-plugin-sdk'; +import plugin from './plugin.mjs'; + +const host = createPluginHost({grants: ['document.read', 'language.provide']}); +try { + await host.activate(plugin); + const nested = 'sum(scale(2, 3), )'; + host.setDocument({uri: 'memory:///teaching.dds', languageId: 'teaching', modelVersion: 1, workspaceRevision: 'demo', text: nested}); + const inner = await host.requestLanguage('signature-help', {position: {line: 0, character: nested.indexOf('3')}, context: {triggerKind: 'invoked', isRetrigger: false}}); + assert.equal(inner.data.signatures[0].label, 'scale(left, right)'); + assert.equal(inner.data.activeParameter, 1); + const outer = await host.requestLanguage('signature-help', {position: {line: 0, character: nested.length - 1}}); + assert.equal(outer.data.signatures[0].label, 'sum(left, right)'); + assert.equal(outer.data.signatures.length, 2); + assert.equal(outer.data.activeParameter, 1); + host.setDocument({uri: 'memory:///teaching.dds', languageId: 'teaching', modelVersion: 2, workspaceRevision: 'demo', text: '// 😀\r\nsu'}); + const selected = await host.requestLanguage('completion', {position: {line: 1, character: 2}}); + assert.equal(selected.data[0].resolveData, undefined); + const detailed = await host.resolveCompletion(selected.data[0].resolveToken); + assert.ok(detailed.data[0].documentation); + const preview = host.prepareCompletion(detailed); + assert.equal(preview.content, '// 😀\r\nsum(left, right)'); + assert.deepEqual(preview.tabstops.map(stop => stop.index), [1, 2, 0]); + assert.deepEqual(preview.tabstops[0].ranges[0], {start: {line: 1, character: 4}, end: {line: 1, character: 8}}); + // A host UI can display this preview; no document or file is changed by these calls. + host.releaseCompletion(selected); host.releaseCompletion(detailed); + console.log('nested signature help, lazy completion resolution and Unicode snippet preview verified'); +} finally {host.dispose();} diff --git a/package-lock.json b/package-lock.json index 3956889..6eb7315 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@altifigence/dds-plugin-sdk", - "version": "0.10.0", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@altifigence/dds-plugin-sdk", - "version": "0.10.0", + "version": "0.11.0", "license": "Apache-2.0", "bin": { "dds-plugin": "bin/dds-plugin.mjs" diff --git a/package.json b/package.json index 548240a..192439a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@altifigence/dds-plugin-sdk", - "version": "0.10.0", + "version": "0.11.0", "description": "Plugin, theme and user-owned workspace tools for Digital Design Studio", "type": "module", "license": "Apache-2.0", @@ -180,7 +180,8 @@ "example:transfer-queue": "node examples/transfer-queue/run.mjs", "example:transfer-queue-browser": "node examples/transfer-queue/serve.mjs", "benchmark:transfer-queue": "node scripts/benchmark-transfer-queue.mjs", - "example:browser-resume": "node examples/browser-resume/serve.mjs" + "example:browser-resume": "node examples/browser-resume/serve.mjs", + "example:assistance": "node examples/language-assistance/run.mjs" }, "devDependencies": { "typescript": "5.9.3" diff --git a/schemas/artifact-sink-capabilities.schema.json b/schemas/artifact-sink-capabilities.schema.json index f21df37..dcaffe8 100644 --- a/schemas/artifact-sink-capabilities.schema.json +++ b/schemas/artifact-sink-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/artifact-sink-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/artifact-sink-capabilities.schema.json", "title": "artifact-sink-capabilities", "type": "object", "properties": { diff --git a/schemas/artifact-storage-capabilities.schema.json b/schemas/artifact-storage-capabilities.schema.json index 6fb79ec..7442843 100644 --- a/schemas/artifact-storage-capabilities.schema.json +++ b/schemas/artifact-storage-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/artifact-storage-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/artifact-storage-capabilities.schema.json", "title": "artifact-storage-capabilities", "oneOf": [ { diff --git a/schemas/artifact-transfer-receipt.schema.json b/schemas/artifact-transfer-receipt.schema.json index ba9fab5..3308cb3 100644 --- a/schemas/artifact-transfer-receipt.schema.json +++ b/schemas/artifact-transfer-receipt.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/artifact-transfer-receipt.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/artifact-transfer-receipt.schema.json", "title": "artifact-transfer-receipt", "type": "object", "properties": { diff --git a/schemas/binary-artifact-capabilities.schema.json b/schemas/binary-artifact-capabilities.schema.json index 7258978..1e941b4 100644 --- a/schemas/binary-artifact-capabilities.schema.json +++ b/schemas/binary-artifact-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/binary-artifact-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/binary-artifact-capabilities.schema.json", "title": "binary-artifact-capabilities", "type": "object", "properties": { diff --git a/schemas/binary-artifact-chunk.schema.json b/schemas/binary-artifact-chunk.schema.json index 3920fad..9a5f4b3 100644 --- a/schemas/binary-artifact-chunk.schema.json +++ b/schemas/binary-artifact-chunk.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/binary-artifact-chunk.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/binary-artifact-chunk.schema.json", "title": "binary-artifact-chunk", "type": "object", "properties": { diff --git a/schemas/binary-artifact-list.schema.json b/schemas/binary-artifact-list.schema.json index aef9095..44e2d27 100644 --- a/schemas/binary-artifact-list.schema.json +++ b/schemas/binary-artifact-list.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/binary-artifact-list.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/binary-artifact-list.schema.json", "title": "binary-artifact-list", "type": "object", "properties": { diff --git a/schemas/binary-artifact-reference.schema.json b/schemas/binary-artifact-reference.schema.json index 3ed1eed..22dc371 100644 --- a/schemas/binary-artifact-reference.schema.json +++ b/schemas/binary-artifact-reference.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/binary-artifact-reference.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/binary-artifact-reference.schema.json", "title": "binary-artifact-reference", "type": "object", "properties": { diff --git a/schemas/binary-artifact.schema.json b/schemas/binary-artifact.schema.json index 0f40d28..e464d90 100644 --- a/schemas/binary-artifact.schema.json +++ b/schemas/binary-artifact.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/binary-artifact.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/binary-artifact.schema.json", "title": "binary-artifact", "type": "object", "properties": { diff --git a/schemas/browser-artifact-checkpoint.schema.json b/schemas/browser-artifact-checkpoint.schema.json index 052f69d..0189e76 100644 --- a/schemas/browser-artifact-checkpoint.schema.json +++ b/schemas/browser-artifact-checkpoint.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/browser-artifact-checkpoint.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/browser-artifact-checkpoint.schema.json", "title": "browser-artifact-checkpoint", "type": "object", "properties": { diff --git a/schemas/command.schema.json b/schemas/command.schema.json index 5acd415..53bf5d6 100644 --- a/schemas/command.schema.json +++ b/schemas/command.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/command.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/command.schema.json", "title": "command", "type": "object", "properties": { diff --git a/schemas/completion-item.schema.json b/schemas/completion-item.schema.json new file mode 100644 index 0000000..4a2c821 --- /dev/null +++ b/schemas/completion-item.schema.json @@ -0,0 +1,170 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/completion-item.schema.json", + "title": "completion-item", + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "insertText": { + "type": "string", + "minLength": 0, + "maxLength": 16384 + }, + "detail": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "range": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + }, + "end": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false + }, + "documentation": { + "type": "string", + "minLength": 0, + "maxLength": 16384 + }, + "insertTextFormat": { + "enum": [ + "literal", + "snippet" + ] + }, + "additionalTextEdits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "range": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + }, + "end": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false + }, + "text": { + "type": "string", + "minLength": 0, + "maxLength": 16384 + } + }, + "required": [ + "range", + "text" + ], + "additionalProperties": false + }, + "minItems": 0, + "maxItems": 32 + }, + "resolveData": { + "x-maxUtf8Bytes": 4096, + "$comment": "Bounded plain JSON. The host retains this provider data privately and issues a one-use opaque token." + }, + "resolveToken": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "pattern": "^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$" + } + }, + "required": [ + "label", + "insertText" + ], + "additionalProperties": false, + "$comment": "Runtime checks well-formed UTF-16, exact snippet grammar, expanded text, document ranges, non-overlap and final file size. Provider-supplied resolveToken is rejected by the registry. Resolution cannot change label/insertion/format/range." +} diff --git a/schemas/diagnostics-request.schema.json b/schemas/diagnostics-request.schema.json index 96c5cb5..d680583 100644 --- a/schemas/diagnostics-request.schema.json +++ b/schemas/diagnostics-request.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/diagnostics-request.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/diagnostics-request.schema.json", "title": "diagnostics-request", "type": "object", "properties": { diff --git a/schemas/diagnostics-result.schema.json b/schemas/diagnostics-result.schema.json index e088ec6..e7a47f2 100644 --- a/schemas/diagnostics-result.schema.json +++ b/schemas/diagnostics-result.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/diagnostics-result.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/diagnostics-result.schema.json", "title": "diagnostics-result", "type": "object", "properties": { diff --git a/schemas/error.schema.json b/schemas/error.schema.json index fe20ca3..075a765 100644 --- a/schemas/error.schema.json +++ b/schemas/error.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/error.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/error.schema.json", "title": "error", "type": "object", "properties": { diff --git a/schemas/job-artifact-content.schema.json b/schemas/job-artifact-content.schema.json index c2edf10..414d40b 100644 --- a/schemas/job-artifact-content.schema.json +++ b/schemas/job-artifact-content.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/job-artifact-content.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/job-artifact-content.schema.json", "title": "job-artifact-content", "type": "object", "properties": { diff --git a/schemas/job-capabilities.schema.json b/schemas/job-capabilities.schema.json index 56ff56a..0590083 100644 --- a/schemas/job-capabilities.schema.json +++ b/schemas/job-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/job-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/job-capabilities.schema.json", "title": "job-capabilities", "type": "object", "properties": { diff --git a/schemas/job-events.schema.json b/schemas/job-events.schema.json index d35ec19..65a316c 100644 --- a/schemas/job-events.schema.json +++ b/schemas/job-events.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/job-events.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/job-events.schema.json", "title": "job-events", "type": "object", "properties": { diff --git a/schemas/job-history-page.schema.json b/schemas/job-history-page.schema.json index 03e060a..81cb27e 100644 --- a/schemas/job-history-page.schema.json +++ b/schemas/job-history-page.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/job-history-page.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/job-history-page.schema.json", "title": "job-history-page", "type": "object", "properties": { diff --git a/schemas/job-history-query.schema.json b/schemas/job-history-query.schema.json index 1f1cd69..8f53021 100644 --- a/schemas/job-history-query.schema.json +++ b/schemas/job-history-query.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/job-history-query.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/job-history-query.schema.json", "title": "job-history-query", "type": "object", "properties": { diff --git a/schemas/job-options.schema.json b/schemas/job-options.schema.json index 30b0eeb..d450de8 100644 --- a/schemas/job-options.schema.json +++ b/schemas/job-options.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/job-options.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/job-options.schema.json", "title": "job-options", "type": "object", "properties": { diff --git a/schemas/job-recovery.schema.json b/schemas/job-recovery.schema.json index 713c61a..d6928d9 100644 --- a/schemas/job-recovery.schema.json +++ b/schemas/job-recovery.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/job-recovery.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/job-recovery.schema.json", "title": "job-recovery", "oneOf": [ { @@ -123,7 +123,7 @@ "uniqueItems": true }, "snapshot": { - "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/job-snapshot.schema.json" + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/job-snapshot.schema.json" }, "events": { "type": "array", @@ -687,7 +687,7 @@ "uniqueItems": true }, "snapshot": { - "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/job-snapshot.schema.json" + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/job-snapshot.schema.json" }, "events": { "type": "array", @@ -1251,7 +1251,7 @@ "uniqueItems": true }, "snapshot": { - "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/job-snapshot.schema.json" + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/job-snapshot.schema.json" }, "events": { "type": "array", diff --git a/schemas/job-snapshot.schema.json b/schemas/job-snapshot.schema.json index 42fc082..8369b16 100644 --- a/schemas/job-snapshot.schema.json +++ b/schemas/job-snapshot.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/job-snapshot.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/job-snapshot.schema.json", "title": "job-snapshot", "oneOf": [ { @@ -305,7 +305,7 @@ "const": "succeeded" }, "result": { - "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/json-value.schema.json" + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/json-value.schema.json" } }, "required": [ diff --git a/schemas/job-storage-capabilities.schema.json b/schemas/job-storage-capabilities.schema.json index 1edbf34..746daad 100644 --- a/schemas/job-storage-capabilities.schema.json +++ b/schemas/job-storage-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/job-storage-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/job-storage-capabilities.schema.json", "title": "job-storage-capabilities", "oneOf": [ { diff --git a/schemas/job-store-identity.schema.json b/schemas/job-store-identity.schema.json index 9f45add..f2efff8 100644 --- a/schemas/job-store-identity.schema.json +++ b/schemas/job-store-identity.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/job-store-identity.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/job-store-identity.schema.json", "title": "job-store-identity", "type": "object", "properties": { diff --git a/schemas/json-value.schema.json b/schemas/json-value.schema.json index bcf6960..d9be63e 100644 --- a/schemas/json-value.schema.json +++ b/schemas/json-value.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/json-value.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/json-value.schema.json", "title": "json-value", "$ref": "#/$defs/value", "x-maxUtf8Bytes": 262144, diff --git a/schemas/language-capabilities.schema.json b/schemas/language-capabilities.schema.json new file mode 100644 index 0000000..e3272a4 --- /dev/null +++ b/schemas/language-capabilities.schema.json @@ -0,0 +1,95 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/language-capabilities.schema.json", + "title": "language-capabilities", + "type": "object", + "properties": { + "protocolVersion": { + "const": 1 + }, + "features": { + "const": [ + "completion", + "hover", + "definition", + "references", + "document-symbols", + "signature-help" + ] + }, + "completionResolve": { + "const": true + }, + "snippets": { + "const": true + }, + "positions": { + "const": "utf16-zero-based" + }, + "limits": { + "type": "object", + "properties": { + "signatures": { + "const": 16 + }, + "parameters": { + "const": 64 + }, + "signatureLabel": { + "const": 2048 + }, + "documentation": { + "const": 16384 + }, + "snippetChars": { + "const": 16384 + }, + "snippetStops": { + "const": 128 + }, + "additionalEdits": { + "const": 32 + }, + "resolveDataBytes": { + "const": 4096 + }, + "resolveTokens": { + "const": 128 + }, + "resolveBytes": { + "const": 4194304 + }, + "resolveTtlMs": { + "const": 60000 + }, + "resolvePending": { + "const": 4 + } + }, + "required": [ + "signatures", + "parameters", + "signatureLabel", + "documentation", + "snippetChars", + "snippetStops", + "additionalEdits", + "resolveDataBytes", + "resolveTokens", + "resolveBytes", + "resolveTtlMs", + "resolvePending" + ], + "additionalProperties": false + } + }, + "required": [ + "protocolVersion", + "features", + "completionResolve", + "snippets", + "positions", + "limits" + ], + "additionalProperties": false +} diff --git a/schemas/language-request.schema.json b/schemas/language-request.schema.json index 0f3e04a..3882d0f 100644 --- a/schemas/language-request.schema.json +++ b/schemas/language-request.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/language-request.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/language-request.schema.json", "title": "language-request", "oneOf": [ { @@ -96,6 +96,52 @@ "character" ], "additionalProperties": false + }, + "context": { + "oneOf": [ + { + "type": "object", + "properties": { + "triggerKind": { + "const": "invoked" + } + }, + "required": [ + "triggerKind" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "triggerKind": { + "const": "character" + }, + "triggerCharacter": { + "type": "string", + "minLength": 1, + "maxLength": 1 + } + }, + "required": [ + "triggerKind", + "triggerCharacter" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "triggerKind": { + "const": "incomplete" + } + }, + "required": [ + "triggerKind" + ], + "additionalProperties": false + } + ] } }, "required": [ @@ -509,6 +555,199 @@ "kind" ], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "protocolVersion": { + "const": 1 + }, + "requestId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "scope": { + "type": "object", + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "sessionId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "projectId", + "sessionId" + ], + "additionalProperties": false + }, + "snapshot": { + "type": "object", + "properties": { + "uri": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "pattern": "^[A-Za-z][A-Za-z0-9+.-]*:[^\\s\\u0000-\\u001f\\u007f]+$" + }, + "languageId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$" + }, + "modelVersion": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "workspaceRevision": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "text": { + "type": "string", + "maxLength": 262144, + "x-maxUtf8Bytes": 262144 + } + }, + "required": [ + "uri", + "languageId", + "modelVersion", + "workspaceRevision", + "text" + ], + "additionalProperties": false + }, + "kind": { + "const": "signature-help" + }, + "position": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + }, + "context": { + "oneOf": [ + { + "type": "object", + "properties": { + "triggerKind": { + "const": "invoked" + }, + "isRetrigger": { + "type": "boolean" + }, + "activeSignature": { + "type": "integer", + "minimum": 0, + "maximum": 15 + }, + "activeParameter": { + "type": "integer", + "minimum": 0, + "maximum": 63 + } + }, + "required": [ + "triggerKind", + "isRetrigger" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "triggerKind": { + "const": "character" + }, + "triggerCharacter": { + "type": "string", + "minLength": 1, + "maxLength": 1 + }, + "isRetrigger": { + "type": "boolean" + }, + "activeSignature": { + "type": "integer", + "minimum": 0, + "maximum": 15 + }, + "activeParameter": { + "type": "integer", + "minimum": 0, + "maximum": 63 + } + }, + "required": [ + "triggerKind", + "triggerCharacter", + "isRetrigger" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "triggerKind": { + "const": "content-change" + }, + "isRetrigger": { + "type": "boolean" + }, + "activeSignature": { + "type": "integer", + "minimum": 0, + "maximum": 15 + }, + "activeParameter": { + "type": "integer", + "minimum": 0, + "maximum": 63 + } + }, + "required": [ + "triggerKind", + "isRetrigger" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "protocolVersion", + "requestId", + "scope", + "snapshot", + "kind", + "position" + ], + "additionalProperties": false } ] } diff --git a/schemas/language-result.schema.json b/schemas/language-result.schema.json index 31fa758..d3af2e3 100644 --- a/schemas/language-result.schema.json +++ b/schemas/language-result.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/language-result.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/language-result.schema.json", "title": "language-result", "oneOf": [ { @@ -83,6 +83,7 @@ }, "insertText": { "type": "string", + "minLength": 0, "maxLength": 16384 }, "detail": { @@ -139,13 +140,105 @@ "end" ], "additionalProperties": false + }, + "documentation": { + "type": "string", + "minLength": 0, + "maxLength": 16384 + }, + "insertTextFormat": { + "enum": [ + "literal", + "snippet" + ] + }, + "additionalTextEdits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "range": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + }, + "end": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false + }, + "text": { + "type": "string", + "minLength": 0, + "maxLength": 16384 + } + }, + "required": [ + "range", + "text" + ], + "additionalProperties": false + }, + "minItems": 0, + "maxItems": 32 + }, + "resolveData": { + "x-maxUtf8Bytes": 4096, + "$comment": "Bounded plain JSON. The host retains this provider data privately and issues a one-use opaque token." + }, + "resolveToken": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "pattern": "^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$" } }, "required": [ "label", "insertText" ], - "additionalProperties": false + "additionalProperties": false, + "$comment": "Runtime checks well-formed UTF-16, exact snippet grammar, expanded text, document ranges, non-overlap and final file size. Provider-supplied resolveToken is rejected by the registry. Resolution cannot change label/insertion/format/range." }, "minItems": 0, "maxItems": 500 @@ -830,6 +923,176 @@ "data" ], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "protocolVersion": { + "const": 1 + }, + "requestId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "scope": { + "type": "object", + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "sessionId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "projectId", + "sessionId" + ], + "additionalProperties": false + }, + "snapshot": { + "type": "object", + "properties": { + "uri": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "pattern": "^[A-Za-z][A-Za-z0-9+.-]*:[^\\s\\u0000-\\u001f\\u007f]+$" + }, + "languageId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$" + }, + "modelVersion": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "workspaceRevision": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "uri", + "languageId", + "modelVersion", + "workspaceRevision" + ], + "additionalProperties": false + }, + "kind": { + "const": "signature-help" + }, + "data": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "properties": { + "signatures": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "parameters": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 2048 + }, + "minItems": 2, + "maxItems": 2, + "$comment": "Ordered nonempty UTF-16 label offsets within the signature, without split Unicode characters." + }, + "documentation": { + "type": "string", + "minLength": 0, + "maxLength": 16384 + } + }, + "required": [ + "label" + ], + "additionalProperties": false + }, + "minItems": 0, + "maxItems": 64 + }, + "documentation": { + "type": "string", + "minLength": 0, + "maxLength": 16384 + } + }, + "required": [ + "label", + "parameters" + ], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 16 + }, + "activeSignature": { + "type": "integer", + "minimum": 0, + "maximum": 15 + }, + "activeParameter": { + "oneOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 63 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "signatures", + "activeSignature", + "activeParameter" + ], + "additionalProperties": false + } + ], + "$comment": "Runtime additionally checks active indices; activeParameter is null only for the selected signature with no parameters." + } + }, + "required": [ + "protocolVersion", + "requestId", + "scope", + "snapshot", + "kind", + "data" + ], + "additionalProperties": false } ] } diff --git a/schemas/language-snippet.schema.json b/schemas/language-snippet.schema.json new file mode 100644 index 0000000..d73eb5e --- /dev/null +++ b/schemas/language-snippet.schema.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/language-snippet.schema.json", + "title": "language-snippet", + "type": "string", + "minLength": 0, + "maxLength": 16384, + "$comment": "Runtime accepts $n, ${n}, ${n:literal}, one $0 and escapes for backslash/dollar/braces. Indices are 0..99; no variables, nesting, choices or transforms. Mirrored defaults must agree; expansion is <=16384 UTF-16 units and <=128 explicit stops." +} diff --git a/schemas/manifest.schema.json b/schemas/manifest.schema.json index 309732a..f814a46 100644 --- a/schemas/manifest.schema.json +++ b/schemas/manifest.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/manifest.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/manifest.schema.json", "title": "manifest", "properties": { "id": { @@ -185,11 +185,14 @@ "hover", "definition", "references", - "document-symbols" + "document-symbols", + "signature-help", + "completion-resolve", + "completion-snippets" ] }, "minItems": 1, - "maxItems": 7, + "maxItems": 10, "uniqueItems": true }, "permissions": { @@ -297,6 +300,29 @@ } } } + }, + { + "if": { + "properties": { + "capabilities": { + "contains": { + "enum": [ + "completion-resolve", + "completion-snippets" + ] + } + } + } + }, + "then": { + "properties": { + "capabilities": { + "contains": { + "const": "completion" + } + } + } + } } ] } diff --git a/schemas/project-entry.schema.json b/schemas/project-entry.schema.json index 02c3163..6745882 100644 --- a/schemas/project-entry.schema.json +++ b/schemas/project-entry.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/project-entry.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/project-entry.schema.json", "title": "project-entry", "type": "object", "properties": { diff --git a/schemas/project-query-capabilities.schema.json b/schemas/project-query-capabilities.schema.json index cab6b63..0720967 100644 --- a/schemas/project-query-capabilities.schema.json +++ b/schemas/project-query-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/project-query-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/project-query-capabilities.schema.json", "title": "project-query-capabilities", "type": "object", "properties": { diff --git a/schemas/project-query-options.schema.json b/schemas/project-query-options.schema.json index c18f146..6503b3d 100644 --- a/schemas/project-query-options.schema.json +++ b/schemas/project-query-options.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/project-query-options.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/project-query-options.schema.json", "title": "project-query-options", "oneOf": [ { diff --git a/schemas/project-query-page.schema.json b/schemas/project-query-page.schema.json index 0e0671d..7776a02 100644 --- a/schemas/project-query-page.schema.json +++ b/schemas/project-query-page.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/project-query-page.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/project-query-page.schema.json", "title": "project-query-page", "type": "object", "properties": { diff --git a/schemas/project-snapshot.schema.json b/schemas/project-snapshot.schema.json index 6b30a56..bd1a8d3 100644 --- a/schemas/project-snapshot.schema.json +++ b/schemas/project-snapshot.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/project-snapshot.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/project-snapshot.schema.json", "title": "project-snapshot", "type": "object", "properties": { diff --git a/schemas/project-watch-capabilities.schema.json b/schemas/project-watch-capabilities.schema.json index dfed092..d3815fa 100644 --- a/schemas/project-watch-capabilities.schema.json +++ b/schemas/project-watch-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/project-watch-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/project-watch-capabilities.schema.json", "title": "project-watch-capabilities", "type": "object", "properties": { diff --git a/schemas/project-watch-event.schema.json b/schemas/project-watch-event.schema.json index faf6393..600ffdb 100644 --- a/schemas/project-watch-event.schema.json +++ b/schemas/project-watch-event.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/project-watch-event.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/project-watch-event.schema.json", "title": "project-watch-event", "type": "object", "properties": { diff --git a/schemas/project-watch-options.schema.json b/schemas/project-watch-options.schema.json index 1f3b1ee..5c1a95b 100644 --- a/schemas/project-watch-options.schema.json +++ b/schemas/project-watch-options.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/project-watch-options.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/project-watch-options.schema.json", "title": "project-watch-options", "type": "object", "properties": { diff --git a/schemas/signature-help.schema.json b/schemas/signature-help.schema.json new file mode 100644 index 0000000..9c1a517 --- /dev/null +++ b/schemas/signature-help.schema.json @@ -0,0 +1,94 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/signature-help.schema.json", + "title": "signature-help", + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "properties": { + "signatures": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "parameters": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 2048 + }, + "minItems": 2, + "maxItems": 2, + "$comment": "Ordered nonempty UTF-16 label offsets within the signature, without split Unicode characters." + }, + "documentation": { + "type": "string", + "minLength": 0, + "maxLength": 16384 + } + }, + "required": [ + "label" + ], + "additionalProperties": false + }, + "minItems": 0, + "maxItems": 64 + }, + "documentation": { + "type": "string", + "minLength": 0, + "maxLength": 16384 + } + }, + "required": [ + "label", + "parameters" + ], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 16 + }, + "activeSignature": { + "type": "integer", + "minimum": 0, + "maximum": 15 + }, + "activeParameter": { + "oneOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 63 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "signatures", + "activeSignature", + "activeParameter" + ], + "additionalProperties": false + } + ], + "$comment": "Runtime additionally checks active indices; activeParameter is null only for the selected signature with no parameters." +} diff --git a/schemas/stored-artifact-chunk.schema.json b/schemas/stored-artifact-chunk.schema.json index 62a2804..cc59353 100644 --- a/schemas/stored-artifact-chunk.schema.json +++ b/schemas/stored-artifact-chunk.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/stored-artifact-chunk.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/stored-artifact-chunk.schema.json", "title": "stored-artifact-chunk", "type": "object", "properties": { diff --git a/schemas/stored-artifact-list.schema.json b/schemas/stored-artifact-list.schema.json index 3e5bb3b..1559919 100644 --- a/schemas/stored-artifact-list.schema.json +++ b/schemas/stored-artifact-list.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/stored-artifact-list.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/stored-artifact-list.schema.json", "title": "stored-artifact-list", "type": "object", "properties": { diff --git a/schemas/stored-artifact-reference.schema.json b/schemas/stored-artifact-reference.schema.json index 601bc76..a3bbcf4 100644 --- a/schemas/stored-artifact-reference.schema.json +++ b/schemas/stored-artifact-reference.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/stored-artifact-reference.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/stored-artifact-reference.schema.json", "title": "stored-artifact-reference", "type": "object", "properties": { diff --git a/schemas/stored-artifact.schema.json b/schemas/stored-artifact.schema.json index 34afda2..766449b 100644 --- a/schemas/stored-artifact.schema.json +++ b/schemas/stored-artifact.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/stored-artifact.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/stored-artifact.schema.json", "title": "stored-artifact", "type": "object", "properties": { diff --git a/schemas/stored-job.schema.json b/schemas/stored-job.schema.json index 7835782..9dbdaf2 100644 --- a/schemas/stored-job.schema.json +++ b/schemas/stored-job.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/stored-job.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/stored-job.schema.json", "title": "stored-job", "type": "object", "properties": { @@ -77,7 +77,7 @@ "uniqueItems": true }, "snapshot": { - "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/job-snapshot.schema.json" + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/job-snapshot.schema.json" }, "events": { "type": "array", diff --git a/schemas/transfer-queue-options.schema.json b/schemas/transfer-queue-options.schema.json index 9444f4b..c5c8b7f 100644 --- a/schemas/transfer-queue-options.schema.json +++ b/schemas/transfer-queue-options.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/transfer-queue-options.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/transfer-queue-options.schema.json", "title": "transfer-queue-options", "type": "object", "properties": { diff --git a/schemas/transfer-snapshot.schema.json b/schemas/transfer-snapshot.schema.json index db228d5..bf26bb6 100644 --- a/schemas/transfer-snapshot.schema.json +++ b/schemas/transfer-snapshot.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/transfer-snapshot.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/transfer-snapshot.schema.json", "title": "transfer-snapshot", "type": "object", "properties": { diff --git a/schemas/upload-capabilities.schema.json b/schemas/upload-capabilities.schema.json index 0624d4c..debde89 100644 --- a/schemas/upload-capabilities.schema.json +++ b/schemas/upload-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/upload-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/upload-capabilities.schema.json", "title": "upload-capabilities", "type": "object", "properties": { diff --git a/schemas/upload-chunk.schema.json b/schemas/upload-chunk.schema.json index cfbf31b..988ba94 100644 --- a/schemas/upload-chunk.schema.json +++ b/schemas/upload-chunk.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/upload-chunk.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/upload-chunk.schema.json", "title": "upload-chunk", "type": "object", "properties": { diff --git a/schemas/upload-reference.schema.json b/schemas/upload-reference.schema.json index 2b0bec4..69d0875 100644 --- a/schemas/upload-reference.schema.json +++ b/schemas/upload-reference.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/upload-reference.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/upload-reference.schema.json", "title": "upload-reference", "type": "object", "properties": { diff --git a/schemas/upload-spec.schema.json b/schemas/upload-spec.schema.json index 9984e66..f70a039 100644 --- a/schemas/upload-spec.schema.json +++ b/schemas/upload-spec.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/upload-spec.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/upload-spec.schema.json", "title": "upload-spec", "type": "object", "properties": { diff --git a/schemas/upload-status.schema.json b/schemas/upload-status.schema.json index 9a3c863..b32e795 100644 --- a/schemas/upload-status.schema.json +++ b/schemas/upload-status.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/upload-status.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/upload-status.schema.json", "title": "upload-status", "type": "object", "properties": { diff --git a/schemas/workspace-conditional-file.schema.json b/schemas/workspace-conditional-file.schema.json index 0c7169e..9034c8c 100644 --- a/schemas/workspace-conditional-file.schema.json +++ b/schemas/workspace-conditional-file.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/workspace-conditional-file.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/workspace-conditional-file.schema.json", "title": "workspace-conditional-file", "oneOf": [ { diff --git a/schemas/workspace-file-capabilities.schema.json b/schemas/workspace-file-capabilities.schema.json index 9cb33c5..92f2f02 100644 --- a/schemas/workspace-file-capabilities.schema.json +++ b/schemas/workspace-file-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/workspace-file-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/workspace-file-capabilities.schema.json", "title": "workspace-file-capabilities", "type": "object", "properties": { diff --git a/schemas/workspace-file-revision.schema.json b/schemas/workspace-file-revision.schema.json index 8edb3b4..028f3b6 100644 --- a/schemas/workspace-file-revision.schema.json +++ b/schemas/workspace-file-revision.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/workspace-file-revision.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/workspace-file-revision.schema.json", "title": "workspace-file-revision", "type": "object", "properties": { diff --git a/schemas/workspace-project-capabilities.schema.json b/schemas/workspace-project-capabilities.schema.json index e81747a..37583ae 100644 --- a/schemas/workspace-project-capabilities.schema.json +++ b/schemas/workspace-project-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/workspace-project-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/workspace-project-capabilities.schema.json", "title": "workspace-project-capabilities", "type": "object", "properties": { diff --git a/schemas/workspace-project-poll.schema.json b/schemas/workspace-project-poll.schema.json index 61611a7..2ab1625 100644 --- a/schemas/workspace-project-poll.schema.json +++ b/schemas/workspace-project-poll.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/workspace-project-poll.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/workspace-project-poll.schema.json", "title": "workspace-project-poll", "type": "object", "properties": { diff --git a/scripts/pack-check.mjs b/scripts/pack-check.mjs index 74f4c26..14f6e79 100644 --- a/scripts/pack-check.mjs +++ b/scripts/pack-check.mjs @@ -98,6 +98,8 @@ assert.equal(typeof createDiagnosticsResult, 'function'); run(process.execPath, ['smoke.mjs'], consumer); await cp(join(installed, 'examples', 'hello-language'), join(consumer, 'language-example'), {recursive: true}); run(process.execPath, ['language-example/run.mjs'], consumer); + await cp(join(installed, 'examples', 'language-assistance'), join(consumer, 'assistance-example'), {recursive: true}); + assert.match(run(process.execPath, ['assistance-example/run.mjs'], consumer), /nested signature help, lazy completion resolution and Unicode snippet preview verified/); await cp(join(installed, 'examples', 'project-session'), join(consumer, 'project-example'), {recursive: true}); run(process.execPath, ['project-example/run.mjs'], consumer); // Package and install a real independent plugin. npm sees generated metadata; @@ -192,6 +194,7 @@ try { await cp(join(root, 'tests', 'types', 'public-apis.mts'), join(consumer, 'public-apis.mts')); await cp(join(root, 'tests', 'types', 'core-v2.mts'), join(consumer, 'core-v2.mts')); await cp(join(root, 'tests', 'types', 'language.mts'), join(consumer, 'language.mts')); + await cp(join(root, 'tests', 'types', 'language-assistance.mts'), join(consumer, 'language-assistance.mts')); await cp(join(root, 'tests', 'types', 'workspace-project.mts'), join(consumer, 'workspace-project.mts')); await cp(join(root, 'tests', 'types', 'jobs-devtools.mts'), join(consumer, 'jobs-devtools.mts')); await cp(join(root, 'tests', 'types', 'workspace-observation.mts'), join(consumer, 'workspace-observation.mts')); diff --git a/src/contracts.mjs b/src/contracts.mjs index c01e4a3..ed9b33f 100644 --- a/src/contracts.mjs +++ b/src/contracts.mjs @@ -1,5 +1,7 @@ import { ErrorCode, LIMITS, PluginSdkError, PROTOCOL_VERSION } from './limits.mjs'; import { SEMVER_PATTERN } from './patterns.mjs'; +import {parseCompletionItem, parseLanguageContext, parseSignatureHelp, completionEdits} from './language-assistance.mjs'; +import {textIndex} from './language-values.mjs'; const encoder = new TextEncoder(); const identifierPattern = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/; @@ -8,7 +10,8 @@ const uriPattern = /^[A-Za-z][A-Za-z0-9+.-]*:[^\s\u0000-\u001f\u007f]+$/; const permissions = ['document.read', 'diagnostics.publish']; const workspacePermissions = ['workspace.read', 'workspace.write', 'backend.invoke']; const allPermissions = [...permissions, ...workspacePermissions, 'language.provide']; -export const LANGUAGE_FEATURES = Object.freeze(['completion', 'hover', 'definition', 'references', 'document-symbols']); +export const LANGUAGE_FEATURES = Object.freeze(['completion', 'hover', 'definition', 'references', 'document-symbols', 'signature-help']); +export const LANGUAGE_CAPABILITIES = Object.freeze([...LANGUAGE_FEATURES, 'completion-resolve', 'completion-snippets']); function fail(path, message) { throw new PluginSdkError(ErrorCode.INVALID_CONTRACT, `${path}: ${message}`); @@ -162,12 +165,13 @@ export function parseManifest(value) { version: string(value.version, 64, 'manifest.version', semverPattern), protocolVersion: version(value.protocolVersion, 'manifest.protocolVersion'), entry, - capabilities: Object.freeze(unique(array(value.capabilities, manifestVersion === 1 ? 1 : 7, 'manifest.capabilities', (v, p) => enumeration(v, manifestVersion === 1 ? ['diagnostics'] : ['diagnostics', 'commands', ...LANGUAGE_FEATURES], p), 1), 'manifest.capabilities')), + capabilities: Object.freeze(unique(array(value.capabilities, manifestVersion === 1 ? 1 : LANGUAGE_CAPABILITIES.length + 2, 'manifest.capabilities', (v, p) => enumeration(v, manifestVersion === 1 ? ['diagnostics'] : ['diagnostics', 'commands', ...LANGUAGE_CAPABILITIES], p), 1), 'manifest.capabilities')), permissions: Object.freeze(unique(array(value.permissions, manifestVersion === 1 ? 2 : 6, 'manifest.permissions', (v, p) => enumeration(v, manifestVersion === 1 ? permissions : allPermissions, p)), 'manifest.permissions')), supportedHosts: Object.freeze(unique(array(value.supportedHosts, manifestVersion === 1 ? 1 : 2, 'manifest.supportedHosts', (v, p) => enumeration(v, manifestVersion === 1 ? ['test-host'] : ['test-host', 'workspace-host'], p), 1), 'manifest.supportedHosts')), license: manifestVersion === 1 ? string(value.license, 128, 'manifest.license', /^[A-Za-z0-9.+-]+$/) : parseLicenseExpression(value.license), }; if (manifestVersion === 2) { + if (output.capabilities.some(capability => ['completion-resolve', 'completion-snippets'].includes(capability)) && !output.capabilities.includes('completion')) fail('manifest.capabilities', 'completion extension requires completion'); output.runtime = enumeration(value.runtime, ['ui', 'workspace'], 'manifest.runtime'); if (output.runtime === 'ui' && output.permissions.some(permission => workspacePermissions.includes(permission))) fail('manifest.permissions', 'UI plugins cannot request workspace or backend permissions'); record(value.source, ['visibility', 'licenseFile'], ['repository'], 'manifest.source'); @@ -423,10 +427,10 @@ function inDocumentRange(value, text) { inDocument(value.start, text, 'range.start'); inDocument(value.end, text, 'range.end'); } -/** Language API v1: plain text only. Returned text is never HTML or a command. */ +/** Language API v1: returned text is never HTML or a command. Snippets are explicit data. */ export function parseLanguageRequest(value) { value = input(value, LIMITS.requestBytes, 'request'); - record(value, ['protocolVersion', 'requestId', 'scope', 'snapshot', 'kind'], ['position', 'includeDeclaration'], 'request'); + record(value, ['protocolVersion', 'requestId', 'scope', 'snapshot', 'kind'], ['position', 'includeDeclaration', 'context'], 'request'); const kind = enumeration(value.kind, LANGUAGE_FEATURES, 'request.kind'); const output = { protocolVersion: version(value.protocolVersion, 'request.protocolVersion'), @@ -438,21 +442,18 @@ export function parseLanguageRequest(value) { } else { output.position = position(value.position, 'request.position'); inDocument(output.position, output.snapshot.text, 'request.position'); + textIndex(output.snapshot.text).offset(output.position); } if (Object.hasOwn(value, 'includeDeclaration')) { if (kind !== 'references' || typeof value.includeDeclaration !== 'boolean') fail('request.includeDeclaration', 'only references accept a boolean'); output.includeDeclaration = value.includeDeclaration; } + if (Object.hasOwn(value, 'context')) output.context = parseLanguageContext(kind, value.context); return boundCopy(Object.freeze(output), LIMITS.requestBytes, 'request'); } function completion(value, path) { - record(value, ['label', 'insertText'], ['detail', 'range'], path); - const output = {label: string(value.label, 256, `${path}.label`), insertText: parseFileContent(value.insertText)}; - if (value.insertText !== '') string(value.insertText, 16_384, `${path}.insertText`); - if (Object.hasOwn(value, 'detail')) output.detail = string(value.detail, 2048, `${path}.detail`); - if (Object.hasOwn(value, 'range')) output.range = range(value.range, `${path}.range`); - return Object.freeze(output); + return parseCompletionItem(value); } function hover(value, path) { if (value === null) return null; @@ -486,13 +487,19 @@ export function parseLanguageResult(value) { protocolVersion: version(value.protocolVersion, 'result.protocolVersion'), requestId: string(value.requestId, 128, 'result.requestId'), scope: scope(value.scope, 'result.scope'), snapshot: snapshot(value.snapshot, 'result.snapshot', false), kind, - data: kind === 'hover' ? hover(value.data, 'result.data') : Object.freeze(array(value.data, LIMITS.maxLanguageItems, 'result.data', readers[kind])), + data: kind === 'signature-help' ? parseSignatureHelp(value.data) : kind === 'hover' ? hover(value.data, 'result.data') : Object.freeze(array(value.data, LIMITS.maxLanguageItems, 'result.data', readers[kind])), }), LIMITS.resultBytes, 'result'); } export function assertLanguageResultMatchesRequest(result, request) { assertIdentityMatches(result, request); if (result.kind !== request.kind) fail('result.kind', 'does not match request'); + if (result.kind === 'signature-help') return; + if (result.kind === 'completion') { + const index = textIndex(request.snapshot.text); + for (const item of result.data) completionEdits(request, item, index); + return; + } if (['definition', 'references'].includes(result.kind)) return; // Target contents require a separately authorized workspace read. const entries = result.kind === 'hover' ? (result.data ? [result.data] : []) : result.data; const lines = request.snapshot.text.split(/\r\n|\n|\r/); diff --git a/src/host.mjs b/src/host.mjs index 54ad046..192e604 100644 --- a/src/host.mjs +++ b/src/host.mjs @@ -3,6 +3,8 @@ import { parseCommandDefinition, parseCommandInput, parseDiagnosticsRequest, par import { createDiagnosticsRegistry } from './lifecycle.mjs'; import { createLanguageRegistry } from './lifecycle.mjs'; import { LANGUAGE_FEATURES, parseLanguageRequest } from './contracts.mjs'; +import {LANGUAGE_LIMITS} from './language-assistance.mjs'; +import {SDK_VERSION} from './version.mjs'; import { ErrorCode, LIMITS, PluginSdkError } from './limits.mjs'; import {createJobRegistry} from './job-registry.mjs'; import {parseBinaryArtifactSource, parseBinaryChunk,parseBinaryArtifactRange,decodeBinaryArtifactData} from './artifacts.mjs'; @@ -132,7 +134,7 @@ export function createPluginHost({hostId = 'test-host', scope = {projectId: 'exa const state = {active: true, ready: false, manifest, grants: effectiveGrants, commands: new Map(), registrations: new Set(), controller: new AbortController(), disposable: undefined}; plugins.set(manifest.id, state); const context = Object.freeze({ - host: Object.freeze({id: hostId, version: '0.9.0', protocolVersion: 1}), + host: Object.freeze({id: hostId, version: SDK_VERSION, protocolVersion: 1}), pluginId: manifest.id, scope, grants: effectiveGrants, @@ -160,7 +162,10 @@ export function createPluginHost({hostId = 'test-host', scope = {projectId: 'exa if (!languages.has(kind)) throw error(ErrorCode.INVALID_CONTRACT, 'Unknown language feature'); if (!manifest.capabilities.includes(kind)) throw error(ErrorCode.PERMISSION_DENIED, 'Language capability was not declared'); let registration; - try { registration = languages.get(kind).register(manifest.id, selector, provider); } + try { registration = languages.get(kind).register(manifest.id, selector, provider, { + resolve: kind === 'completion' && manifest.capabilities.includes('completion-resolve'), + snippets: kind === 'completion' && manifest.capabilities.includes('completion-snippets'), + }); } catch (failure) { throw error(failure instanceof PluginSdkError ? failure.code : ErrorCode.PROVIDER_FAILED, 'Language provider registration failed'); } state.registrations.add(registration); return Object.freeze({dispose() { @@ -340,13 +345,22 @@ export function createPluginHost({hostId = 'test-host', scope = {projectId: 'exa if (!document) throw error(ErrorCode.INVALID_CONTRACT, 'Set a document before requesting language features'); if (!languages.has(kind)) throw error(ErrorCode.INVALID_CONTRACT, 'Unknown language feature'); const value = parseJsonValue(input); - if (!value || Array.isArray(value) || typeof value !== 'object' || Object.keys(value).some(key => !['position', 'includeDeclaration'].includes(key))) throw error(ErrorCode.INVALID_CONTRACT, 'Expected language request input'); + if (!value || Array.isArray(value) || typeof value !== 'object' || Object.keys(value).some(key => !['position', 'includeDeclaration', 'context'].includes(key))) throw error(ErrorCode.INVALID_CONTRACT, 'Expected language request input'); const request = parseLanguageRequest({protocolVersion: 1, requestId: `request-${++nextRequest}`, scope, snapshot: document, kind, ...value}); const currentRevision = revision; const result = await languages.get(kind).request(request, options); if (revision !== currentRevision) throw error(ErrorCode.STALE_SNAPSHOT, 'Document changed while language features were pending'); return result; }, + languageCapabilities() {assertOpen(); return Object.freeze({protocolVersion: 1, features: LANGUAGE_FEATURES, completionResolve: true, snippets: true, positions: 'utf16-zero-based', limits: LANGUAGE_LIMITS});}, + async resolveCompletion(token, options = {}) { + assertOpen(); const currentRevision = revision; + const result = await languages.get('completion').resolve(token, options); + if (revision !== currentRevision) throw error(ErrorCode.STALE_SNAPSHOT, 'Document changed while completion resolution was pending'); + return result; + }, + prepareCompletion(result, itemIndex = 0) {assertOpen(); return languages.get('completion').prepareCompletion(result, itemIndex);}, + releaseCompletion(result) {assertOpen(); languages.get('completion').releaseCompletion(result);}, listPlugins() {assertOpen(); return Object.freeze([...plugins.values()].filter(state => state.ready && state.active).map(state => state.manifest));}, listCommands() { assertOpen(); diff --git a/src/index.d.mts b/src/index.d.mts index ce0527f..4cee8b7 100644 --- a/src/index.d.mts +++ b/src/index.d.mts @@ -35,7 +35,7 @@ export interface PluginManifestV1 { export interface PluginManifestV2 extends Omit { readonly manifestVersion: 2; readonly runtime: 'ui' | 'workspace'; - readonly capabilities: readonly ('diagnostics' | 'commands' | LanguageFeature)[]; + readonly capabilities: readonly ('diagnostics' | 'commands' | LanguageCapability)[]; readonly permissions: readonly Permission[]; readonly supportedHosts: readonly HostId[]; readonly source: { @@ -161,6 +161,10 @@ export interface PluginHost extends Disposable { setDocument(snapshot: DocumentSnapshot): DocumentSnapshot; requestDiagnostics(options?: RequestOptions): Promise; requestLanguage(kind: K, input: LanguageInput, options?: RequestOptions): Promise>; + languageCapabilities(): LanguageCapabilities; + resolveCompletion(token: string, options?: RequestOptions): Promise>; + prepareCompletion(result: LanguageResult<'completion'>, itemIndex?: number): CompletionInsertion; + releaseCompletion(result: LanguageResult<'completion'>): void; listCommands(): readonly RegisteredCommand[]; executeCommand(pluginId: string, commandId: string, input: JsonValue, options?: RequestOptions): Promise; jobCapabilities(): JobCapabilities; @@ -188,15 +192,53 @@ export interface PluginHost extends Disposable { /** Executes trusted plugins in-process. Ports enforce file/backend authority; this is not a sandbox. */ export function createPluginHost(options?: PluginHostOptions): PluginHost; -export type LanguageFeature = 'completion' | 'hover' | 'definition' | 'references' | 'document-symbols'; +export type LanguageFeature = 'completion' | 'hover' | 'definition' | 'references' | 'document-symbols' | 'signature-help'; +export type LanguageCapability = LanguageFeature | 'completion-resolve' | 'completion-snippets'; export const LANGUAGE_FEATURES: readonly LanguageFeature[]; +export const LANGUAGE_CAPABILITIES: readonly LanguageCapability[]; export type LanguageInput = K extends 'document-symbols' - ? {readonly position?: never; readonly includeDeclaration?: never} - : {readonly position: Position} & (K extends 'references' ? {readonly includeDeclaration?: boolean} : {readonly includeDeclaration?: never}); + ? {readonly position?: never; readonly includeDeclaration?: never; readonly context?: never} + : {readonly position: Position} & (K extends 'references' ? {readonly includeDeclaration?: boolean; readonly context?: never} + : K extends 'completion' ? {readonly includeDeclaration?: never; readonly context?: CompletionContext} + : K extends 'signature-help' ? {readonly includeDeclaration?: never; readonly context?: SignatureHelpContext} + : {readonly includeDeclaration?: never; readonly context?: never}); export type LanguageRequest = K extends LanguageFeature ? DiagnosticsRequest & {readonly kind: K} & LanguageInput : never; -/** Literal insertion and description text, never snippets, HTML or executable commands. */ -export interface CompletionItem {readonly label: string; readonly insertText: string; readonly detail?: string; readonly range?: Range;} +/** Literal by default. A declared snippet capability enables the bounded data-only subset. */ +export interface CompletionItem { + readonly label: string; readonly insertText: string; readonly detail?: string; readonly range?: Range; + readonly documentation?: string; readonly insertTextFormat?: 'literal' | 'snippet'; + readonly additionalTextEdits?: readonly {readonly range: Range; readonly text: string}[]; + /** Provider-owned bounded JSON; retained privately by the registry. */ + readonly resolveData?: JsonValue; + /** Host-issued, one-use token. A provider cannot supply this field. */ + readonly resolveToken?: string; +} +export type CompletionContext = {readonly triggerKind: 'invoked' | 'incomplete'; readonly triggerCharacter?: never} | {readonly triggerKind: 'character'; readonly triggerCharacter: string}; +export type SignatureHelpContext = ({readonly triggerKind: 'invoked' | 'content-change'; readonly triggerCharacter?: never} | {readonly triggerKind: 'character'; readonly triggerCharacter: string}) & {readonly isRetrigger: boolean; readonly activeSignature?: number; readonly activeParameter?: number}; +export interface SignatureInformation { + readonly label: string; readonly documentation?: string; + readonly parameters: readonly {readonly label: readonly [number, number]; readonly documentation?: string}[]; +} +export interface SignatureHelp {readonly signatures: readonly SignatureInformation[]; readonly activeSignature: number; readonly activeParameter: number | null;} +export interface ParsedSnippet {readonly text: string; readonly tabstops: readonly {readonly index: number; readonly ranges: readonly {readonly start: number; readonly end: number}[]}[];} +export interface CompletionInsertion { + readonly snapshot: SnapshotIdentity; readonly content: string; + readonly edits: readonly {readonly range: Range; readonly text: string}[]; + readonly tabstops: readonly {readonly index: number; readonly ranges: readonly Range[]}[]; +} +export const LANGUAGE_LIMITS: Readonly<{ + signatures: number; parameters: number; signatureLabel: number; documentation: number; + snippetChars: number; snippetStops: number; additionalEdits: number; + resolveDataBytes: number; resolveTokens: number; resolveBytes: number; resolveTtlMs: number; resolvePending: number; +}>; +export interface LanguageCapabilities { + readonly protocolVersion: 1; readonly features: readonly LanguageFeature[]; + readonly completionResolve: true; readonly snippets: true; readonly positions: 'utf16-zero-based'; readonly limits: typeof LANGUAGE_LIMITS; +} +export function parseSnippet(value: unknown): ParsedSnippet; +export function parseCompletionItem(value: unknown): CompletionItem; +export function parseSignatureHelp(value: unknown): SignatureHelp | null; export interface Hover {readonly text: string; readonly range?: Range;} /** Relative workspace path. Hosts must authorize access before opening the target. */ export interface LanguageLocation {readonly path: string; readonly range: Range;} @@ -210,15 +252,20 @@ export interface LanguageData { readonly definition: readonly LanguageLocation[]; readonly references: readonly LanguageLocation[]; readonly 'document-symbols': readonly DocumentSymbol[]; + readonly 'signature-help': SignatureHelp | null; } export type LanguageResult = K extends LanguageFeature ? Omit & {readonly kind: K; readonly data: LanguageData[K]} : never; export interface LanguageProvider { provide(request: LanguageRequest, options: {readonly signal: AbortSignal}): LanguageResult | Promise>; + readonly resolve?: K extends 'completion' ? (request: LanguageRequest<'completion'>, item: CompletionItem, options: {readonly signal: AbortSignal}) => CompletionItem | Promise : never; } export interface LanguageRegistry extends Disposable { - register(pluginId: string, selector: ProviderSelector, provider: LanguageProvider): Disposable; + register(pluginId: string, selector: ProviderSelector, provider: LanguageProvider, options?: K extends 'completion' ? {readonly resolve?: boolean; readonly snippets?: boolean} : never): Disposable; request(request: LanguageRequest, options?: RequestOptions): Promise>; + resolve(token: string, options?: RequestOptions): Promise>; + prepareCompletion(result: LanguageResult<'completion'>, itemIndex?: number): CompletionInsertion; + releaseCompletion(result: LanguageResult<'completion'>): void; invalidate(): void; } export function parseLanguageRequest(value: unknown): LanguageRequest; diff --git a/src/index.mjs b/src/index.mjs index d889330..803bb9d 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -6,7 +6,8 @@ export { parseManifest, parseDocumentSnapshot, parseDiagnosticsRequest, parseDia export { parseCommandDefinition, parseJsonValue, parseLicenseExpression, parseWorkspacePath } from './contracts.mjs'; export { createDiagnosticsRegistry } from './lifecycle.mjs'; export { createLanguageRegistry } from './lifecycle.mjs'; -export { LANGUAGE_FEATURES, parseLanguageRequest, parseLanguageResult, createLanguageResult } from './contracts.mjs'; +export { LANGUAGE_FEATURES, LANGUAGE_CAPABILITIES, parseLanguageRequest, parseLanguageResult, createLanguageResult } from './contracts.mjs'; +export {LANGUAGE_LIMITS, parseSnippet, parseCompletionItem, parseSignatureHelp} from './language-assistance.mjs'; export { createPluginHost } from './host.mjs'; /** Declare an ESM plugin. Activation is performed by a host with explicit grants. */ diff --git a/src/language-assistance-schemas.mjs b/src/language-assistance-schemas.mjs new file mode 100644 index 0000000..92264fc --- /dev/null +++ b/src/language-assistance-schemas.mjs @@ -0,0 +1,29 @@ +import {LANGUAGE_LIMITS as limits} from './language-assistance.mjs'; +import {LANGUAGE_FEATURES} from './contracts.mjs'; +const object = (properties, required = Object.keys(properties)) => ({type: 'object', properties, required, additionalProperties: false}); +const text = (maxLength, minLength = 0) => ({type: 'string', minLength, maxLength}); +const integer = (maximum, minimum = 0) => ({type: 'integer', minimum, maximum}); +const list = (items, maxItems, minItems = 0) => ({type: 'array', items, minItems, maxItems}); +const position = object({line: integer(262_144), character: integer(262_144)}); +const range = object({start: position, end: position}); +const edit = object({range, text: text(16_384)}); +const token = {...text(36, 36), pattern: '^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$'}; +export const COMPLETION_ITEM_SCHEMA = { + ...object({label: text(256, 1), insertText: text(16_384), detail: text(2048, 1), range, + documentation: text(limits.documentation), insertTextFormat: {enum: ['literal', 'snippet']}, additionalTextEdits: list(edit, limits.additionalEdits), + resolveData: {'x-maxUtf8Bytes': limits.resolveDataBytes, $comment: 'Bounded plain JSON. The host retains this provider data privately and issues a one-use opaque token.'}, resolveToken: token, + }, ['label', 'insertText']), + $comment: 'Runtime checks well-formed UTF-16, exact snippet grammar, expanded text, document ranges, non-overlap and final file size. Provider-supplied resolveToken is rejected by the registry. Resolution cannot change label/insertion/format/range.', +}; +const parameter = object({label: {...list(integer(limits.signatureLabel), 2, 2), $comment: 'Ordered nonempty UTF-16 label offsets within the signature, without split Unicode characters.'}, documentation: text(limits.documentation)}, ['label']); +export const SIGNATURE_HELP_SCHEMA = {oneOf: [{type: 'null'}, object({signatures: list(object({label: text(limits.signatureLabel, 1), parameters: list(parameter, limits.parameters), documentation: text(limits.documentation)}, ['label', 'parameters']), limits.signatures, 1), activeSignature: integer(limits.signatures - 1), activeParameter: {oneOf: [integer(limits.parameters - 1), {type: 'null'}]}})], $comment: 'Runtime additionally checks active indices; activeParameter is null only for the selected signature with no parameters.'}; +export function languageContextSchema(kind) { + const signature = kind === 'signature-help'; + return {oneOf: (signature ? ['invoked', 'character', 'content-change'] : ['invoked', 'character', 'incomplete']).map(triggerKind => object({triggerKind: {const: triggerKind}, ...(triggerKind === 'character' ? {triggerCharacter: text(1, 1)} : {}), ...(signature ? {isRetrigger: {type: 'boolean'}, activeSignature: integer(limits.signatures - 1), activeParameter: integer(limits.parameters - 1)} : {})}, ['triggerKind', ...(triggerKind === 'character' ? ['triggerCharacter'] : []), ...(signature ? ['isRetrigger'] : [])]))}; +} +export const LANGUAGE_ASSISTANCE_SCHEMAS = Object.freeze({ + 'completion-item': COMPLETION_ITEM_SCHEMA, + 'signature-help': SIGNATURE_HELP_SCHEMA, + 'language-snippet': {...text(limits.snippetChars), $comment: 'Runtime accepts $n, ${n}, ${n:literal}, one $0 and escapes for backslash/dollar/braces. Indices are 0..99; no variables, nesting, choices or transforms. Mirrored defaults must agree; expansion is <=16384 UTF-16 units and <=128 explicit stops.'}, + 'language-capabilities': object({protocolVersion: {const: 1}, features: {const: LANGUAGE_FEATURES}, completionResolve: {const: true}, snippets: {const: true}, positions: {const: 'utf16-zero-based'}, limits: object(Object.fromEntries(Object.entries(limits).map(([key, value]) => [key, {const: value}])))}), +}); diff --git a/src/language-assistance.mjs b/src/language-assistance.mjs new file mode 100644 index 0000000..c83d403 --- /dev/null +++ b/src/language-assistance.mjs @@ -0,0 +1,149 @@ +import {languageCopy, languageObject, languageText, languageInteger, languageList, languageRange, textIndex, freezeLanguage, invalidLanguage, languageCall} from './language-values.mjs'; +import {applyTextEdits} from './text-edits.mjs'; + +export const LANGUAGE_LIMITS = Object.freeze({ + signatures: 16, parameters: 64, signatureLabel: 2048, documentation: 16_384, + snippetChars: 16_384, snippetStops: 128, additionalEdits: 32, + resolveDataBytes: 4096, resolveTokens: 128, resolveBytes: 4_194_304, + resolveTtlMs: 60_000, resolvePending: 4, +}); + +/** A bounded snippet subset. The result is plain text and positions, never executable content. */ +export function parseSnippet(value) { + const source = languageText(value, LANGUAGE_LIMITS.snippetChars, true); + const segments = [], defaults = new Map(); let literal = '', count = 0, final = false; + const flush = () => {if (literal) {segments.push({text: literal}); literal = '';}}; + for (let i = 0; i < source.length;) { + if (source[i] === '\\') { + if (!['\\', '$', '}', '{'].includes(source[i + 1])) invalidLanguage('Unsupported snippet escape'); + literal += source[i + 1]; i += 2; continue; + } + if (source[i] !== '$') {literal += source[i++]; continue;} + flush(); i++; + const braced = source[i] === '{'; if (braced) i++; + const begin = i; while (/\d/.test(source[i] ?? '') && i < source.length) i++; + const number = source.slice(begin, i); + if (!/^(?:0|[1-9][0-9]?)$/.test(number)) invalidLanguage('Unsupported snippet expression'); + const index = Number(number); let defaultText; + if (braced && source[i] === ':') { + if (index === 0) invalidLanguage('Final tabstop cannot have a placeholder'); + i++; defaultText = ''; + while (i < source.length && source[i] !== '}') { + if (source[i] === '\\') { + if (!['\\', '$', '}', '{'].includes(source[i + 1])) invalidLanguage('Unsupported snippet escape'); + defaultText += source[i + 1]; i += 2; + } else { + if (source[i] === '$' || source[i] === '{') invalidLanguage('Nested snippets and variables are unsupported'); + defaultText += source[i++]; + } + } + } + if (braced && source[i++] !== '}') invalidLanguage('Unclosed or unsupported snippet expression'); + if (++count > LANGUAGE_LIMITS.snippetStops) invalidLanguage('Snippet tabstop limit exceeded'); + if (index === 0) {if (final) invalidLanguage('Duplicate final tabstop'); final = true;} + if (defaultText !== undefined) { + if (defaults.has(index) && defaults.get(index) !== defaultText) invalidLanguage('Conflicting mirrored placeholders'); + defaults.set(index, defaultText); + } + segments.push({index}); + } + flush(); + let text = ''; const groups = new Map(); + for (const segment of segments) { + if (Object.hasOwn(segment, 'text')) {text += segment.text; continue;} + const start = text.length; text += defaults.get(segment.index) ?? ''; + if (!groups.has(segment.index)) groups.set(segment.index, []); + groups.get(segment.index).push({start, end: text.length}); + if (text.length > LANGUAGE_LIMITS.snippetChars) invalidLanguage('Expanded snippet limit exceeded'); + } + languageText(text, LANGUAGE_LIMITS.snippetChars, true); + if (!final) groups.set(0, [{start: text.length, end: text.length}]); + const index = textIndex(text); + for (const ranges of groups.values()) for (const range of ranges) {index.position(range.start); index.position(range.end);} + return freezeLanguage({text, tabstops: [...groups].sort(([a], [b]) => (a || 100) - (b || 100)).map(([index, ranges]) => ({index, ranges}))}); +} + +export function parseCompletionItem(input) { + const value = languageCopy(input, 131_072); + languageObject(value, ['label', 'insertText'], ['detail', 'range', 'documentation', 'insertTextFormat', 'additionalTextEdits', 'resolveData', 'resolveToken']); + const legacyText = (text, maximum, empty = false) => { + languageText(text, maximum * 2, empty); + if ([...text].length > maximum) invalidLanguage(); + }; + legacyText(value.label, 256); legacyText(value.insertText, 16_384, true); + if (value.detail !== undefined) legacyText(value.detail, 2048); + if (value.documentation !== undefined) languageText(value.documentation, LANGUAGE_LIMITS.documentation, true); + if (value.range !== undefined) languageRange(value.range); + if (value.insertTextFormat !== undefined && !['literal', 'snippet'].includes(value.insertTextFormat)) invalidLanguage(); + if (value.insertTextFormat === 'snippet') parseSnippet(value.insertText); + if (value.additionalTextEdits !== undefined) for (const edit of languageList(value.additionalTextEdits, LANGUAGE_LIMITS.additionalEdits)) { + languageObject(edit, ['range', 'text']); languageRange(edit.range); languageText(edit.text, 16_384, true); + } + if (value.resolveData !== undefined) languageCopy(value.resolveData, LANGUAGE_LIMITS.resolveDataBytes); + if (value.resolveToken !== undefined && (typeof value.resolveToken !== 'string' || !/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(value.resolveToken))) invalidLanguage(); + return freezeLanguage(value); +} + +export function parseLanguageContext(kind, input) { + const value = languageCopy(input, 2048), signature = kind === 'signature-help'; + if (!signature && kind !== 'completion') invalidLanguage('Context is unsupported for this language feature'); + languageObject(value, signature ? ['triggerKind', 'isRetrigger'] : ['triggerKind'], signature ? ['triggerCharacter', 'activeSignature', 'activeParameter'] : ['triggerCharacter']); + if (!(signature ? ['invoked', 'character', 'content-change'] : ['invoked', 'character', 'incomplete']).includes(value.triggerKind)) invalidLanguage(); + if (value.triggerKind === 'character') { + languageText(value.triggerCharacter, 2); if ([...value.triggerCharacter].length !== 1 || /[\r\n\u0000]/.test(value.triggerCharacter)) invalidLanguage(); + } else if (value.triggerCharacter !== undefined) invalidLanguage(); + if (signature) { + if (typeof value.isRetrigger !== 'boolean') invalidLanguage(); + for (const key of ['activeSignature', 'activeParameter']) if (value[key] !== undefined) { + if (!value.isRetrigger) invalidLanguage(); + languageInteger(value[key], key === 'activeSignature' ? LANGUAGE_LIMITS.signatures - 1 : LANGUAGE_LIMITS.parameters - 1); + } + } + return freezeLanguage(value); +} + +export function parseSignatureHelp(input) { + const value = languageCopy(input, 524_288); if (value === null) return null; + languageObject(value, ['signatures', 'activeSignature', 'activeParameter']); + for (const signature of languageList(value.signatures, LANGUAGE_LIMITS.signatures, 1)) { + languageObject(signature, ['label', 'parameters'], ['documentation']); languageText(signature.label, LANGUAGE_LIMITS.signatureLabel); + if (signature.documentation !== undefined) languageText(signature.documentation, LANGUAGE_LIMITS.documentation, true); + const index = textIndex(signature.label); + for (const parameter of languageList(signature.parameters, LANGUAGE_LIMITS.parameters)) { + languageObject(parameter, ['label'], ['documentation']); + languageList(parameter.label, 2, 2); + const [start, end] = parameter.label; + languageInteger(start, signature.label.length); languageInteger(end, signature.label.length, start + 1); + index.position(start); index.position(end); + if (parameter.documentation !== undefined) languageText(parameter.documentation, LANGUAGE_LIMITS.documentation, true); + } + } + languageInteger(value.activeSignature, value.signatures.length - 1); + const parameters = value.signatures[value.activeSignature].parameters; + if (parameters.length) languageInteger(value.activeParameter, parameters.length - 1); + else if (value.activeParameter !== null) invalidLanguage('Parameter must be null for a signature without parameters'); + return freezeLanguage(value); +} + +export function completionEdits(request, item, index = textIndex(request.snapshot.text)) { + const range = item.range ?? {start: request.position, end: request.position}; + index.range(range); + const snippet = item.insertTextFormat === 'snippet' ? parseSnippet(item.insertText) : {text: item.insertText, tabstops: []}; + const edits = [{range, text: snippet.text}, ...(item.additionalTextEdits ?? [])]; + for (const edit of edits) index.range(edit.range); + const content = languageCall(() => applyTextEdits(request.snapshot.text, edits)); + return {content, edits, snippet}; +} + +export function completionInsertion(request, item) { + const original = textIndex(request.snapshot.text), {content, edits, snippet} = completionEdits(request, item, original); + const mainStart = original.offset(edits[0].range.start); + let shift = 0; + for (const edit of edits.slice(1)) { + const {start, end} = original.range(edit.range); + if (start < mainStart) shift += edit.text.length - (end - start); + } + const updated = textIndex(content), base = mainStart + shift; + const {text: _, ...snapshot} = request.snapshot; + return freezeLanguage({snapshot, content, edits, tabstops: snippet.tabstops.map(stop => ({index: stop.index, ranges: stop.ranges.map(range => ({start: updated.position(base + range.start), end: updated.position(base + range.end)}))}))}); +} diff --git a/src/language-registry.mjs b/src/language-registry.mjs new file mode 100644 index 0000000..0c7bf32 --- /dev/null +++ b/src/language-registry.mjs @@ -0,0 +1,163 @@ +import {parseProviderSelector, parseLanguageRequest, parseLanguageResult, assertLanguageResultMatchesRequest, createLanguageResult, LANGUAGE_FEATURES} from './contracts.mjs'; +import {LANGUAGE_LIMITS, parseCompletionItem, completionInsertion} from './language-assistance.mjs'; +import {languageObject, languageInteger, invalidLanguage} from './language-values.mjs'; +import {ErrorCode, LIMITS, PluginSdkError} from './limits.mjs'; + +const failure = (code, message) => new PluginSdkError(code, message); +const sameRange = (a, b) => ['start', 'end'].every(key => a[key].line === b[key].line && a[key].character === b[key].character); +function providerMethod(provider, name) { + if (!provider || typeof provider !== 'object') return undefined; + let owner = provider; + for (let depth = 0; owner && owner !== Object.prototype && depth < 16; depth++, owner = Object.getPrototypeOf(owner)) { + const descriptor = Object.getOwnPropertyDescriptor(owner, name); + if (descriptor) {if (typeof descriptor.value !== 'function') invalidLanguage('Expected provider data methods'); return descriptor.value;} + } + return undefined; +} + +/** Trusted host primitive. Plugins remain in-process; grants belong to the owning host. */ +export function createLanguageRegistry(kind, {isCurrent = () => true} = {}) { + if (!LANGUAGE_FEATURES.includes(kind) || typeof isCurrent !== 'function') invalidLanguage(); + const registrations = new Set(), live = new Set(), tokens = new Map(), results = new WeakMap(); + let disposed = false, sequence = 0, epoch = 0, tokenBytes = 0, resolving = 0; + const open = () => {if (disposed) throw failure(ErrorCode.DISPOSED, 'Language registry is disposed');}; + const current = (request, expectedEpoch = epoch) => { + open(); + if (epoch !== expectedEpoch || !isCurrent(request)) throw failure(ErrorCode.STALE_SNAPSHOT, 'Language snapshot is no longer current'); + }; + function removeToken(id) {const entry = tokens.get(id); if (entry) {tokenBytes -= entry.bytes; tokens.delete(id);}} + function purgeTokens() {const now = Date.now(); for (const [id, entry] of tokens) if (entry.expiresAt <= now || !entry.provider.active) removeToken(id);} + function clearTokens() {tokens.clear(); tokenBytes = 0;} + function abortEntry(entry, code) {for (const ticket of live) if (ticket.entry === entry) ticket.controller.abort(failure(code, 'Language provider is no longer active'));} + function register(pluginId, selector, provider, options = {}) { + open(); + if (typeof pluginId !== 'string' || !pluginId.length || pluginId.length > 128) invalidLanguage(); + selector = parseProviderSelector(selector); languageObject(options, [], ['resolve', 'snippets']); + for (const value of Object.values(options)) if (typeof value !== 'boolean') invalidLanguage(); + if (kind !== 'completion' && (options.resolve || options.snippets)) invalidLanguage(); + const provide = providerMethod(provider, 'provide'), resolve = providerMethod(provider, 'resolve'); + if (!provide) invalidLanguage('Expected provider data methods'); + if (resolve && !options.resolve || options.resolve && !resolve) throw failure(ErrorCode.PERMISSION_DENIED, 'Completion resolve needs an explicit capability'); + if (registrations.size >= LIMITS.maxRegistrations) throw failure(ErrorCode.BUDGET_EXCEEDED, 'Language provider limit exceeded'); + const entry = {pluginId, selector, provider, provide, resolve, snippets: options.snippets === true, sequence: sequence++, active: true}; + registrations.add(entry); + return Object.freeze({dispose() { + if (!entry.active) return; + entry.active = false; registrations.delete(entry); abortEntry(entry, ErrorCode.DISPOSED); + for (const [id, token] of tokens) if (token.provider === entry) removeToken(id); + }}); + } + async function invoke(entry, request, operation, options = {}, resolution = false) { + languageObject(options, [], ['signal', 'timeoutMs']); + const {signal, timeoutMs = LIMITS.defaultTimeoutMs} = options; + languageInteger(timeoutMs, LIMITS.maxTimeoutMs, 1); + if (signal !== undefined && !(signal instanceof AbortSignal)) invalidLanguage(); + const generation = epoch; current(request, generation); + if (!entry.active) throw failure(ErrorCode.DISPOSED, 'Language provider is no longer active'); + if (signal?.aborted) throw failure(ErrorCode.CANCELLED, 'Language request cancelled'); + if (live.size >= LIMITS.maxPendingRequests || resolution && resolving >= LANGUAGE_LIMITS.resolvePending) throw failure(ErrorCode.BUDGET_EXCEEDED, 'Language pending operation limit exceeded'); + const controller = new AbortController(), ticket = {entry, controller}; + live.add(ticket); if (resolution) resolving++; + const abort = () => controller.abort(failure(ErrorCode.CANCELLED, 'Language request cancelled')); + signal?.addEventListener('abort', abort, {once: true}); + let onAbort; + const cancelled = new Promise((_, reject) => {onAbort = () => reject(controller.signal.reason); controller.signal.addEventListener('abort', onAbort, {once: true});}); + const timer = setTimeout(() => controller.abort(failure(ErrorCode.BUDGET_EXCEEDED, 'Language request timed out')), timeoutMs); + const operationPromise = Promise.resolve().then(() => { + if (controller.signal.aborted) throw controller.signal.reason; + current(request, generation); + return operation(Object.freeze({signal: controller.signal})); + }).catch(error => { + if (controller.signal.aborted) throw controller.signal.reason; + throw failure(ErrorCode.PROVIDER_FAILED, 'Language provider failed'); + }).finally(() => {live.delete(ticket); if (resolution) resolving--;}); + try { + const raw = await Promise.race([operationPromise, cancelled]); + if (controller.signal.aborted) throw controller.signal.reason; + current(request, generation); + if (!entry.active) throw failure(ErrorCode.DISPOSED, 'Language provider is no longer active'); + return raw; + } finally { + clearTimeout(timer); signal?.removeEventListener('abort', abort); controller.signal.removeEventListener('abort', onAbort); + // An ignored cancellation retains its live slot until the provider actually settles. + } + } + function checkedResult(raw, request, entry, allowTokens) { + let result = parseLanguageResult(raw); assertLanguageResultMatchesRequest(result, request); + const additions = []; let bytes = 0; + if (kind === 'completion') { + purgeTokens(); + const data = result.data.map(item => { + if (item.resolveToken !== undefined) invalidLanguage('Providers cannot supply host resolve tokens'); + if (item.insertTextFormat === 'snippet' && !entry.snippets) throw failure(ErrorCode.PERMISSION_DENIED, 'Snippet capability was not declared'); + const {resolveData, ...visible} = item; + if (resolveData !== undefined && allowTokens) { + if (!entry.resolve) throw failure(ErrorCode.CAPABILITY_UNAVAILABLE, 'Completion resolver is unavailable'); + if (typeof globalThis.crypto?.randomUUID !== 'function') throw failure(ErrorCode.CAPABILITY_UNAVAILABLE, 'Secure token generation is unavailable'); + const id = globalThis.crypto.randomUUID(), size = new TextEncoder().encode(JSON.stringify({request, item})).byteLength; + additions.push([id, {provider: entry, request, item, epoch, bytes: size, expiresAt: Date.now() + LANGUAGE_LIMITS.resolveTtlMs}]); bytes += size; + visible.resolveToken = id; + } + return visible; + }); + if (tokens.size + additions.length > LANGUAGE_LIMITS.resolveTokens || tokenBytes + bytes > LANGUAGE_LIMITS.resolveBytes) throw failure(ErrorCode.BUDGET_EXCEEDED, 'Completion resolve retention limit exceeded'); + result = parseLanguageResult({...result, data}); + } + for (const [id, entry] of additions) tokens.set(id, entry); + tokenBytes += bytes; results.set(result, {request, entry, epoch, tokenIds: additions.map(([id]) => id)}); + return result; + } + return Object.freeze({ + register, + async request(input, options) { + open(); const request = parseLanguageRequest(input); + if (request.kind !== kind) invalidLanguage('Language feature does not match registry'); + const generation = epoch; current(request, generation); purgeTokens(); + const entry = [...registrations].filter(entry => entry.selector.languages.includes(request.snapshot.languageId)).sort((a, b) => b.selector.priority - a.selector.priority || a.sequence - b.sequence)[0]; + if (!entry) throw failure(ErrorCode.PROVIDER_UNAVAILABLE, 'No language provider matches the document'); + const raw = await invoke(entry, request, options => entry.provide.call(entry.provider, request, options), options); + current(request, generation); + return checkedResult(raw, request, entry, true); + }, + async resolve(token, options) { + open(); purgeTokens(); + if (kind !== 'completion') throw failure(ErrorCode.CAPABILITY_UNAVAILABLE, 'This feature has no resolver'); + if (typeof token !== 'string' || token.length !== 36) invalidLanguage('Invalid completion resolve token'); + const saved = tokens.get(token); + if (!saved) throw failure(ErrorCode.STALE_SNAPSHOT, 'Completion resolve token is unknown or expired'); + current(saved.request, saved.epoch); removeToken(token); + const raw = await invoke(saved.provider, saved.request, options => saved.provider.resolve.call(saved.provider.provider, saved.request, saved.item, options), options, true); + current(saved.request, saved.epoch); + const item = parseCompletionItem(raw), previous = saved.item; + const initialRange = previous.range ?? {start: saved.request.position, end: saved.request.position}; + const resolvedRange = item.range ?? {start: saved.request.position, end: saved.request.position}; + if (item.label !== previous.label || item.insertText !== previous.insertText || (item.insertTextFormat ?? 'literal') !== (previous.insertTextFormat ?? 'literal') || !sameRange(initialRange, resolvedRange)) invalidLanguage('Completion resolver changed the selected insertion'); + return checkedResult(createLanguageResult(saved.request, [item]), saved.request, saved.provider, false); + }, + prepareCompletion(result, itemIndex = 0) { + open(); if (kind !== 'completion') invalidLanguage(); + const saved = results.get(result); + if (!saved) throw failure(ErrorCode.STALE_SNAPSHOT, 'Completion result does not belong to this registry'); + current(saved.request, saved.epoch); + if (!saved.entry.active) throw failure(ErrorCode.DISPOSED, 'Completion provider is no longer active'); + languageInteger(itemIndex, result.data.length - 1); + return completionInsertion(saved.request, result.data[itemIndex]); + }, + releaseCompletion(result) { + open(); + const saved = results.get(result); if (!saved) return; + for (const id of saved.tokenIds) removeToken(id); + results.delete(result); + }, + invalidate() { + open(); epoch++; clearTokens(); + for (const ticket of live) ticket.controller.abort(failure(ErrorCode.STALE_SNAPSHOT, 'Language snapshot is no longer current')); + }, + dispose() { + if (disposed) return; + disposed = true; epoch++; clearTokens(); + for (const entry of registrations) {entry.active = false; abortEntry(entry, ErrorCode.DISPOSED);} + registrations.clear(); + }, + }); +} diff --git a/src/language-values.mjs b/src/language-values.mjs new file mode 100644 index 0000000..1091f8c --- /dev/null +++ b/src/language-values.mjs @@ -0,0 +1,63 @@ +import {copyWorkspaceJson, exactObject, requireFileContent} from './workspace-values.mjs'; +import {ErrorCode, PluginSdkError} from './limits.mjs'; + +export const invalidLanguage = (message = 'Invalid language contract') => {throw new PluginSdkError(ErrorCode.INVALID_CONTRACT, message);}; +export function languageCall(operation) { + try {return operation();} + catch (error) { + if (error instanceof PluginSdkError) throw error; + throw new PluginSdkError(error?.code === 'budget_exceeded' ? ErrorCode.BUDGET_EXCEEDED : ErrorCode.INVALID_CONTRACT, 'Invalid language contract'); + } +} +export const languageCopy = (value, maxBytes = 1_600_000) => languageCall(() => copyWorkspaceJson(value, {maxBytes, maxNodes: 60_000, maxDepth: 24})); +export const languageObject = (value, required, optional = []) => languageCall(() => exactObject(value, required, optional)); +export function languageText(value, maximum, empty = false) { + if (typeof value !== 'string' || !value.isWellFormed() || value.length > maximum || !empty && !value.length) invalidLanguage(); + return value; +} +export function languageInteger(value, maximum, minimum = 0) { + if (!Number.isSafeInteger(value) || value < minimum || value > maximum) invalidLanguage(); + return value; +} +export function languageList(value, maximum, minimum = 0) { + if (!Array.isArray(value) || value.length < minimum || value.length > maximum) invalidLanguage(); + return value; +} +export function freezeLanguage(value) { + if (value && typeof value === 'object') {for (const item of Object.values(value)) freezeLanguage(item); Object.freeze(value);} + return value; +} +export function languagePosition(value) { + languageObject(value, ['line', 'character']); + languageInteger(value.line, 262_144); languageInteger(value.character, 262_144); + return value; +} +export const compareLanguagePosition = (a, b) => a.line - b.line || a.character - b.character; +export function languageRange(value) { + languageObject(value, ['start', 'end']); languagePosition(value.start); languagePosition(value.end); + if (compareLanguagePosition(value.start, value.end) > 0) invalidLanguage('Language range is reversed'); + return value; +} +export function textIndex(content) { + languageCall(() => requireFileContent(content)); + const starts = [0], ends = []; + for (const newline of content.matchAll(/\r\n|\r|\n/g)) {ends.push(newline.index); starts.push(newline.index + newline[0].length);} + ends.push(content.length); + function boundary(index) { + const before = content.charCodeAt(index - 1), after = content.charCodeAt(index); + if (before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff) invalidLanguage('Position splits a Unicode character'); + } + function offset(position) { + languagePosition(position); + if (position.line >= starts.length || position.character > ends[position.line] - starts[position.line]) invalidLanguage('Position is outside the document'); + const index = starts[position.line] + position.character; boundary(index); return index; + } + function position(index) { + languageInteger(index, content.length); boundary(index); + let lo = 0, hi = starts.length; + while (lo + 1 < hi) {const mid = (lo + hi) >>> 1; if (starts[mid] <= index) lo = mid; else hi = mid;} + if (index > ends[lo]) invalidLanguage('Position splits a CRLF newline'); + return {line: lo, character: index - starts[lo]}; + } + return {offset, position, range(value) {languageRange(value); return {start: offset(value.start), end: offset(value.end)};}}; +} diff --git a/src/lifecycle.mjs b/src/lifecycle.mjs index b6d4aa3..0eb2142 100644 --- a/src/lifecycle.mjs +++ b/src/lifecycle.mjs @@ -1,4 +1,5 @@ -import { assertResultMatchesRequest, parseDiagnosticsRequest, parseDiagnosticsResult, parseProviderSelector, parseLanguageRequest, parseLanguageResult, assertLanguageResultMatchesRequest, LANGUAGE_FEATURES } from './contracts.mjs'; +import { assertResultMatchesRequest, parseDiagnosticsRequest, parseDiagnosticsResult, parseProviderSelector } from './contracts.mjs'; +export {createLanguageRegistry} from './language-registry.mjs'; import { ErrorCode, LIMITS, PluginSdkError } from './limits.mjs'; const sdkError = (code, message) => new PluginSdkError(code, message); @@ -8,15 +9,6 @@ export function createDiagnosticsRegistry({isCurrent = () => true} = {}) { return createProviderRegistry({isCurrent, parseRequest: parseDiagnosticsRequest, parseResult: parseDiagnosticsResult, assertMatches: assertResultMatchesRequest, method: 'provideDiagnostics'}); } -export function createLanguageRegistry(kind, {isCurrent = () => true} = {}) { - if (!LANGUAGE_FEATURES.includes(kind)) throw sdkError(ErrorCode.INVALID_CONTRACT, 'Unknown language feature'); - return createProviderRegistry({isCurrent, parseRequest(value) { - const request = parseLanguageRequest(value); - if (request.kind !== kind) throw sdkError(ErrorCode.INVALID_CONTRACT, 'Language feature does not match registry'); - return request; - }, parseResult: parseLanguageResult, assertMatches: assertLanguageResultMatchesRequest, method: 'provide'}); -} - function createProviderRegistry({isCurrent, parseRequest, parseResult, assertMatches, method}) { if (typeof isCurrent !== 'function') throw sdkError(ErrorCode.INVALID_CONTRACT, 'isCurrent must be a function'); const registrations = new Set(); diff --git a/src/publishing.mjs b/src/publishing.mjs index 4824981..71b8431 100644 --- a/src/publishing.mjs +++ b/src/publishing.mjs @@ -1,3 +1,4 @@ +import {SDK_PLUGIN_PEER_RANGE} from './version.mjs'; import { createHash } from 'node:crypto'; import { constants } from 'node:fs'; import { lstat, realpath, open, mkdir, writeFile, unlink } from 'node:fs/promises'; @@ -156,13 +157,13 @@ function inspectContents(contents, archived = false) { name: packageName, version: manifest.version, type: 'module', description: manifest.name, main: `./${entry}`, exports: `./${entry}`, license: manifest.license.includes('LicenseRef-') ? `SEE LICENSE IN ${licenseFile}` : manifest.license, - peerDependencies: {'@altifigence/dds-plugin-sdk': '>=0.10.0 <0.11.0'}, + peerDependencies: {'@altifigence/dds-plugin-sdk': SDK_PLUGIN_PEER_RANGE}, }; const metadataBytes = Buffer.from(`${JSON.stringify(metadata, null, 2)}\n`); if (archived) { // Only the generated inert npm metadata is accepted, including the tested // SDK line. Additional scripts or dependencies cannot be smuggled into it. - const accepted = [metadataBytes, ...['>=0.3.0 <0.4.0', '>=0.4.0 <0.5.0', '>=0.5.0 <0.6.0', '>=0.6.0 <0.7.0', '>=0.7.0 <0.8.0', '>=0.8.0 <0.9.0', '>=0.9.0 <0.10.0'].map(range => Buffer.from(`${JSON.stringify({...metadata, peerDependencies: {'@altifigence/dds-plugin-sdk': range}}, null, 2)}\n`))]; + const accepted = [metadataBytes, ...['>=0.3.0 <0.4.0', '>=0.4.0 <0.5.0', '>=0.5.0 <0.6.0', '>=0.6.0 <0.7.0', '>=0.7.0 <0.8.0', '>=0.8.0 <0.9.0', '>=0.9.0 <0.10.0', '>=0.10.0 <0.11.0'].map(range => Buffer.from(`${JSON.stringify({...metadata, peerDependencies: {'@altifigence/dds-plugin-sdk': range}}, null, 2)}\n`))]; if (!accepted.some(bytes => contents.get('package.json').equals(bytes))) fail('package.json must match the generated SDK metadata'); } else { contents.set('package.json', metadataBytes); diff --git a/src/schemas.mjs b/src/schemas.mjs index be405ab..c13603f 100644 --- a/src/schemas.mjs +++ b/src/schemas.mjs @@ -13,10 +13,12 @@ import {TRANSFER_QUEUE_SCHEMAS} from './transfer-queue-schemas.mjs'; import { SEMVER_PATTERN } from './patterns.mjs'; import { THEME_SCHEMA } from './themes.mjs'; import {WORKSPACE_LIMITS} from './workspace-protocol.mjs'; -import { LANGUAGE_FEATURES } from './contracts.mjs'; +import { LANGUAGE_FEATURES, LANGUAGE_CAPABILITIES } from './contracts.mjs'; +import {LANGUAGE_ASSISTANCE_SCHEMAS, COMPLETION_ITEM_SCHEMA, SIGNATURE_HELP_SCHEMA, languageContextSchema} from './language-assistance-schemas.mjs'; +import {SDK_VERSION} from './version.mjs'; const schema = 'https://json-schema.org/draft/2020-12/schema'; -const base = 'https://github.com/Altifigence/dds-plugin-sdk/blob/v0.10.0/schemas/'; +const base = `https://github.com/Altifigence/dds-plugin-sdk/blob/v${SDK_VERSION}/schemas/`; const text = maxLength => ({type: 'string', minLength: 1, maxLength}); const integer = (maximum, minimum = 0) => ({type: 'integer', minimum, maximum}); const object = (properties, required = Object.keys(properties)) => ({type: 'object', properties, required, additionalProperties: false}); @@ -48,7 +50,7 @@ const manifestV1 = object({ }); const manifestV2 = object({ manifestVersion: {const: 2}, ...commonManifest, runtime: {enum: ['ui', 'workspace']}, - capabilities: list({enum: ['diagnostics', 'commands', ...LANGUAGE_FEATURES]}, 7, 1, true), + capabilities: list({enum: ['diagnostics', 'commands', ...LANGUAGE_CAPABILITIES]}, LANGUAGE_CAPABILITIES.length + 2, 1, true), permissions: list({enum: ['document.read', 'diagnostics.publish', 'workspace.read', 'workspace.write', 'backend.invoke', 'language.provide']}, 6, 0, true), supportedHosts: list({enum: ['test-host', 'workspace-host']}, 2, 1, true), license: {...text(512), pattern: '^[A-Za-z0-9.+:()\\s-]+$', 'x-licenseExpression': true, $comment: 'Runtime validates bounded SPDX-style expression grammar; this is not legal permission.'}, @@ -59,6 +61,7 @@ const manifestV2 = object({ }, ['visibility', 'licenseFile']), }); manifestV2.allOf = [{if: {properties: {runtime: {const: 'ui'}}}, then: {properties: {permissions: {items: {enum: ['document.read', 'diagnostics.publish', 'language.provide']}}}}}]; +manifestV2.allOf.push({if: {properties: {capabilities: {contains: {enum: ['completion-resolve', 'completion-snippets']}}}}, then: {properties: {capabilities: {contains: {const: 'completion'}}}}}); const commandParameter = object({ name: identifier(128), label: text(128), type: {enum: ['string', 'number', 'boolean']}, required: {type: 'boolean'}, choices: list(text(256), 32, 1, true), @@ -72,7 +75,8 @@ function define(name, body) { const range = object({start: position, end: position}); const location = object({path: {...text(1024), $comment: 'Runtime additionally enforces a relative, traversal-free workspace path.'}, range}); const languageData = { - completion: list(object({label: text(256), insertText: {type: 'string', maxLength: 16_384}, detail: text(2048), range}, ['label', 'insertText']), LIMITS.maxLanguageItems), + completion: list(COMPLETION_ITEM_SCHEMA, LIMITS.maxLanguageItems), + 'signature-help': SIGNATURE_HELP_SCHEMA, hover: {oneOf: [{type: 'null'}, object({text: text(16_384), range}, ['text'])]}, definition: list(location, LIMITS.maxLanguageItems), references: list(location, LIMITS.maxLanguageItems), 'document-symbols': list(object({name: text(256), kind: {enum: ['module', 'namespace', 'class', 'interface', 'function', 'method', 'variable', 'constant', 'property', 'type']}, range, selectionRange: range, detail: text(2048)}, ['name', 'kind', 'range', 'selectionRange']), LIMITS.maxLanguageItems), @@ -101,6 +105,7 @@ const historyDisposition = {enum:['live','completed','interrupted','expired']}; const historyCursor = {...text(40),pattern:'^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}:(?:0|[1-9][0-9]{0,2})$'}; const historyItem = object({jobId,commandId:text(128),state:{enum:JOB_STATES},disposition:historyDisposition,startedAt:integer(Number.MAX_SAFE_INTEGER),updatedAt:integer(Number.MAX_SAFE_INTEGER),expiresAt:integer(Number.MAX_SAFE_INTEGER,1),revision:integer(Number.MAX_SAFE_INTEGER,1),attemptOf:{oneOf:[jobId,{type:'null'}]},contentPolicy:{enum:['metadata-only','host-redacted']},artifactCount:integer(JOB_LIMITS.artifacts),snapshotCount:integer(JOB_LIMITS.artifacts),resultAvailability:{enum:['none','source-references','snapshot-references','mixed-references','expired']}}); export const SCHEMAS = Object.freeze({ + ...Object.fromEntries(Object.entries(LANGUAGE_ASSISTANCE_SCHEMAS).map(([name, body]) => [name, define(name, body)])), ...Object.fromEntries(Object.entries({...PROJECT_WATCH_SCHEMAS,...PROJECT_QUERY_SCHEMAS,...WORKSPACE_PROJECT_SCHEMAS,...ARTIFACT_TRANSFER_SCHEMAS,...UPLOAD_SCHEMAS,...TRANSFER_QUEUE_SCHEMAS}).map(([name,body])=>[name,define(name,body)])), 'stored-artifact':define('stored-artifact',{...storedArtifact,$comment:'Runtime checks identity, labels are excluded, and capturedAt/expiresAt ordering and maximum retention.'}), 'browser-artifact-checkpoint':define('browser-artifact-checkpoint',{ @@ -154,7 +159,7 @@ export const SCHEMAS = Object.freeze({ 'language-request': define('language-request', {oneOf: LANGUAGE_FEATURES.map(kind => object({ protocolVersion: {const: 1}, requestId: text(128), scope, snapshot: object({...identity, text: {type: 'string', maxLength: LIMITS.documentBytes, 'x-maxUtf8Bytes': LIMITS.documentBytes}}), - kind: {const: kind}, ...(kind === 'document-symbols' ? {} : {position}), ...(kind === 'references' ? {includeDeclaration: {type: 'boolean'}} : {}), + kind: {const: kind}, ...(kind === 'document-symbols' ? {} : {position}), ...(kind === 'references' ? {includeDeclaration: {type: 'boolean'}} : {}), ...(['completion', 'signature-help'].includes(kind) ? {context: languageContextSchema(kind)} : {}), }, ['protocolVersion', 'requestId', 'scope', 'snapshot', 'kind', ...(kind === 'document-symbols' ? [] : ['position'])]))}), 'language-result': define('language-result', {oneOf: LANGUAGE_FEATURES.map(kind => object({ protocolVersion: {const: 1}, requestId: text(128), scope, snapshot: object(identity), kind: {const: kind}, data: languageData[kind], diff --git a/src/text-edits.mjs b/src/text-edits.mjs new file mode 100644 index 0000000..7de4363 --- /dev/null +++ b/src/text-edits.mjs @@ -0,0 +1,33 @@ +import {copyWorkspaceJson, exactObject, requireFileContent, workspaceFailure} from './workspace-values.mjs'; + +/** Apply non-overlapping UTF-16 edits to one immutable text snapshot. No I/O. */ +export function applyTextEdits(content, value) { + requireFileContent(content); + const edits = copyWorkspaceJson(value); + if (!Array.isArray(edits) || edits.length > 500) throw workspaceFailure('invalid_request', 'Expected at most 500 text edits'); + const starts = [0], ends = []; + for (const newline of content.matchAll(/\r\n|\r|\n/g)) { ends.push(newline.index); starts.push(newline.index + newline[0].length); } + ends.push(content.length); + function offset(position) { + exactObject(position, ['line', 'character']); + if (!Number.isSafeInteger(position.line) || !Number.isSafeInteger(position.character) || position.line < 0 || position.line >= starts.length || position.character < 0 || position.character > ends[position.line] - starts[position.line]) throw workspaceFailure('invalid_request', 'Edit position is outside the document'); + const index = starts[position.line] + position.character; + const before = content.charCodeAt(index - 1), after = content.charCodeAt(index); + if (before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff) throw workspaceFailure('invalid_request', 'Edit position splits a Unicode character'); + return index; + } + const ordered = edits.map(edit => { + exactObject(edit, ['range', 'text']); exactObject(edit.range, ['start', 'end']); + const start = offset(edit.range.start), end = offset(edit.range.end); + if (end < start) throw workspaceFailure('invalid_request', 'Edit range is reversed'); + return {start, end, text: requireFileContent(edit.text)}; + }).sort((a, b) => a.start - b.start || a.end - b.end); + const parts = []; let cursor = 0, previousStart = -1; + for (const edit of ordered) { + if (edit.start < cursor || edit.start === previousStart) throw workspaceFailure('invalid_request', 'Text edits overlap or share an ambiguous insertion point'); + parts.push(content.slice(cursor, edit.start), edit.text); cursor = edit.end; previousStart = edit.start; + } + parts.push(content.slice(cursor)); + return requireFileContent(parts.join('')); +} + diff --git a/src/version.mjs b/src/version.mjs new file mode 100644 index 0000000..4258c96 --- /dev/null +++ b/src/version.mjs @@ -0,0 +1,3 @@ +// The release checks compare this value with the package metadata. +export const SDK_VERSION = '0.11.0'; +export const SDK_PLUGIN_PEER_RANGE = '>=0.11.0 <0.12.0'; diff --git a/src/workspace-node.mjs b/src/workspace-node.mjs index 2f256cc..b25db6f 100644 --- a/src/workspace-node.mjs +++ b/src/workspace-node.mjs @@ -5,6 +5,7 @@ import {createHash, randomUUID, timingSafeEqual} from 'node:crypto'; import {createServer} from 'node:http'; import {spawn} from 'node:child_process'; import {createPluginHost} from './index.mjs'; +import {SDK_VERSION} from './version.mjs'; import {JOB_LIMITS} from './jobs.mjs'; import {BINARY_ARTIFACT_LIMITS, parseBinaryArtifactRange} from './artifacts.mjs'; import {JOB_STORE_LIMITS} from './job-storage.mjs'; @@ -325,7 +326,7 @@ export async function createWorkspaceServer({workspace,root,workspaceId,name='Us const commands=pluginHost.listCommands(); return pluginHost.listPlugins().map(manifest=>({manifest,...pins.get(manifest.id),commands:commands.filter(command=>command.pluginId===manifest.id)})); }; - const description=()=>parseWorkspaceHello({hostId:'workspace-host',hostVersion:'0.10.0',protocolVersion:1,workspace:{id:workspaceId,name,generation},capabilities:{read:true,write:workspace.capabilities?.write===true,manage:workspace.capabilities?.manage===true,commands:pins.size>0},plugins:metadata(),notice:noticePayload}); + const description=()=>parseWorkspaceHello({hostId:'workspace-host',hostVersion:SDK_VERSION,protocolVersion:1,workspace:{id:workspaceId,name,generation},capabilities:{read:true,write:workspace.capabilities?.write===true,manage:workspace.capabilities?.manage===true,commands:pins.size>0},plugins:metadata(),notice:noticePayload}); let originalDescription; try{originalDescription=description();}catch(failure){await projectTransport.close();pluginHost.dispose();workspace.dispose?.();throw safeOperationFailure(failure);} const originalMetadata=JSON.stringify({plugins:originalDescription.plugins,capabilities:originalDescription.capabilities}); diff --git a/src/workspace-project.mjs b/src/workspace-project.mjs index 6ca59bc..1fc9916 100644 --- a/src/workspace-project.mjs +++ b/src/workspace-project.mjs @@ -1,36 +1,8 @@ import {copyWorkspaceJson, exactObject, requireFileContent, requireWorkspacePath, WORKSPACE_LIMITS, workspaceFailure} from './workspace-protocol.mjs'; import {watchWorkspaceFiles} from './workspace-observation.mjs'; -/** Apply non-overlapping UTF-16 edits to one immutable text snapshot. No I/O. */ -export function applyTextEdits(content, value) { - requireFileContent(content); - const edits = copyWorkspaceJson(value); - if (!Array.isArray(edits) || edits.length > 500) throw workspaceFailure('invalid_request', 'Expected at most 500 text edits'); - const starts = [0], ends = []; - for (const newline of content.matchAll(/\r\n|\r|\n/g)) { ends.push(newline.index); starts.push(newline.index + newline[0].length); } - ends.push(content.length); - function offset(position) { - exactObject(position, ['line', 'character']); - if (!Number.isSafeInteger(position.line) || !Number.isSafeInteger(position.character) || position.line < 0 || position.line >= starts.length || position.character < 0 || position.character > ends[position.line] - starts[position.line]) throw workspaceFailure('invalid_request', 'Edit position is outside the document'); - const index = starts[position.line] + position.character; - const before = content.charCodeAt(index - 1), after = content.charCodeAt(index); - if (before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff) throw workspaceFailure('invalid_request', 'Edit position splits a Unicode character'); - return index; - } - const ordered = edits.map(edit => { - exactObject(edit, ['range', 'text']); exactObject(edit.range, ['start', 'end']); - const start = offset(edit.range.start), end = offset(edit.range.end); - if (end < start) throw workspaceFailure('invalid_request', 'Edit range is reversed'); - return {start, end, text: requireFileContent(edit.text)}; - }).sort((a, b) => a.start - b.start || a.end - b.end); - const parts = []; let cursor = 0, previousStart = -1; - for (const edit of ordered) { - if (edit.start < cursor || edit.start === previousStart) throw workspaceFailure('invalid_request', 'Text edits overlap or share an ambiguous insertion point'); - parts.push(content.slice(cursor, edit.start), edit.text); cursor = edit.end; previousStart = edit.start; - } - parts.push(content.slice(cursor)); - return requireFileContent(parts.join('')); -} +import {applyTextEdits} from './text-edits.mjs'; +export {applyTextEdits} from './text-edits.mjs'; /** Bind project operations to one explicit client connection; reconnection requires a new project. */ export function createWorkspaceProject(client) { diff --git a/tests/language-assistance.test.mjs b/tests/language-assistance.test.mjs new file mode 100644 index 0000000..0d3570c --- /dev/null +++ b/tests/language-assistance.test.mjs @@ -0,0 +1,181 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createPluginHost, createLanguageRegistry, definePlugin, createLanguageResult, parseLanguageRequest, parseSignatureHelp, parseSnippet, parseCompletionItem, parseManifest, LANGUAGE_LIMITS, LIMITS} from '../src/index.mjs'; +import {deferred, hasCode} from './fixtures.mjs'; +import {SDK_VERSION} from '../src/version.mjs'; +import {readFile} from 'node:fs/promises'; + +const snapshot = {uri: 'memory:///demo.dds', languageId: 'dds-demo', modelVersion: 1, workspaceRevision: 'one', text: 'let 😀 = 1;\r\nsu'}; +const position = {line: 1, character: 2}; +const range = {start: {line: 1, character: 0}, end: position}; +const item = {label: 'sum', insertText: 'sum(${1:left}, ${2:right})$0', range, insertTextFormat: 'snippet', resolveData: {symbol: 'sum'}}; +const signature = {signatures: [{label: 'sum(left, right)', parameters: [{label: [4, 8]}, {label: [10, 15]}]}], activeSignature: 0, activeParameter: 1}; +const manifest = {manifestVersion: 2, id: 'assistance', name: 'Assistance', publisher: 'example', version: '1.0.0', protocolVersion: 1, entry: './plugin.mjs', runtime: 'ui', capabilities: ['completion', 'signature-help', 'completion-resolve', 'completion-snippets'], permissions: ['document.read', 'language.provide'], supportedHosts: ['test-host'], license: 'Apache-2.0', source: {visibility: 'open', licenseFile: 'LICENSE'}}; +async function fixture(t, {provide = request => createLanguageResult(request, [item]), resolve = (_request, selected) => ({...selected, documentation: 'Plain text only'}), capabilities = manifest.capabilities} = {}) { + const host = createPluginHost({grants: ['document.read', 'language.provide']}); t.after(() => host.dispose()); + let registration; + await host.activate(definePlugin({...manifest, capabilities}, context => { + registration = context.registerLanguageProvider('completion', {languages: ['dds-demo']}, {provide, ...(resolve ? {resolve} : {})}); + if (capabilities.includes('signature-help')) context.registerLanguageProvider('signature-help', {languages: ['dds-demo']}, {provide: request => createLanguageResult(request, signature)}); + })); + host.setDocument(snapshot); + return {host, registration, request: () => host.requestLanguage('completion', {position})}; +} + +test('snippets expand forward mirrors, Unicode, CRLF and escaped literals into bounded ordered stops', () => { + const parsed = parseSnippet('$2:${1:😀}\r\n${2:x}-$1$0'); + assert.equal(parsed.text, 'x:😀\r\nx-😀'); + assert.deepEqual(parsed.tabstops.map(x => x.index), [1, 2, 0]); + assert.deepEqual(parsed.tabstops[0].ranges, [{start: 2, end: 4}, {start: 8, end: 10}]); + assert.equal(parseSnippet(String.raw`\$ \{ \} \\`).text, '$ { } \\'); + assert.deepEqual(parseSnippet('').tabstops, [{index: 0, ranges: [{start: 0, end: 0}]}]); + assert.ok(Object.isFrozen(parsed.tabstops[0].ranges)); +}); + +test('snippets reject variables, transforms, nesting, malformed escapes, inconsistent mirrors and over-expansion', () => { + for (const text of ['$NAME', '${name}', '${1:x${2:y}}', '${1|a,b|}', '${1/a/b/}', '$01', '$100', '${0:x}', '$0$0', '${1:x}${1:y}', '${1:x', '$', '\\q', '${1:\r}\n', '${1:😀}$1'.repeat(65)]) assert.throws(() => parseSnippet(text), hasCode('invalid_contract'), text); + assert.throws(() => parseSnippet('${1:' + 'x'.repeat(9000) + '}$1'), hasCode('invalid_contract')); + assert.throws(() => parseSnippet('x'.repeat(LANGUAGE_LIMITS.snippetChars + 1)), hasCode('invalid_contract')); + assert.throws(() => parseSnippet('$1'.repeat(LANGUAGE_LIMITS.snippetStops + 1)), hasCode('invalid_contract')); +}); + +test('signature help preserves context, overloads and UTF-16 labels; malformed active parameter is rejected', async t => { + const {host} = await fixture(t); + const result = await host.requestLanguage('signature-help', {position, context: {triggerKind: 'character', triggerCharacter: ',', isRetrigger: true, activeSignature: 0, activeParameter: 0}}); + assert.deepEqual(result.data, signature); + assert.equal(host.languageCapabilities().snippets, true); + assert.equal(parseSignatureHelp({signatures: [{label: 'now()', parameters: []}], activeSignature: 0, activeParameter: null}).activeParameter, null); + for (const invalid of [{...signature, activeSignature: 1}, {...signature, activeParameter: 2}, {...signature, activeParameter: null}, {...signature, signatures: [{label: '😀x', parameters: [{label: [1, 2]}]}]}, {...signature, command: 'run'}]) assert.throws(() => parseSignatureHelp(invalid), hasCode('invalid_contract')); + const request = {protocolVersion: 1, requestId: 'one', scope: {projectId: 'p', sessionId: 's'}, snapshot, kind: 'signature-help', position}; + for (const context of [{triggerKind: 'character', triggerCharacter: 'xx', isRetrigger: false}, {triggerKind: 'invoked', triggerCharacter: ',', isRetrigger: false}, {triggerKind: 'invoked', isRetrigger: false, activeParameter: 0}]) assert.throws(() => parseLanguageRequest({...request, context}), hasCode('invalid_contract')); + assert.throws(() => parseLanguageRequest({...request, position: {line: 0, character: 5}}), hasCode('invalid_contract')); +}); + +test('resolve binds the selected insertion and prepares a pure CRLF/Unicode edit with shifted tabstops', async t => { + const {host, request} = await fixture(t, {resolve: (_request, selected) => ({...selected, detail: 'Two numbers', additionalTextEdits: [{range: {start: {line: 0, character: 0}, end: {line: 0, character: 0}}, text: '// header\r\n'}]})}); + const list = await request(); assert.equal(list.data[0].resolveData, undefined); assert.match(list.data[0].resolveToken, /^[a-f0-9-]{36}$/); + const resolved = await host.resolveCompletion(list.data[0].resolveToken); + assert.equal(resolved.data[0].resolveToken, undefined); assert.equal(resolved.data[0].resolveData, undefined); + const preview = host.prepareCompletion(resolved); + assert.equal(preview.content, '// header\r\nlet 😀 = 1;\r\nsum(left, right)'); + assert.deepEqual(preview.tabstops[0].ranges, [{start: {line: 2, character: 4}, end: {line: 2, character: 8}}]); + assert.equal(preview.snapshot.modelVersion, 1); assert.equal(snapshot.text, 'let 😀 = 1;\r\nsu'); + await assert.rejects(host.resolveCompletion(list.data[0].resolveToken), hasCode('stale_snapshot')); + assert.throws(() => host.prepareCompletion(structuredClone(resolved)), hasCode('stale_snapshot')); + host.releaseCompletion(resolved); assert.throws(() => host.prepareCompletion(resolved), hasCode('stale_snapshot')); +}); + +test('resolve rejects changed insertion, executable fields, overlap and range outside the document', async t => { + for (const mutate of [selected => ({...selected, insertText: 'changed'}), selected => ({...selected, range: {start: position, end: position}}), selected => ({...selected, command: 'run'}), selected => ({...selected, additionalTextEdits: [{range, text: 'overlap'}]}), selected => ({...selected, additionalTextEdits: [{range: {start: {line: 9, character: 0}, end: {line: 9, character: 0}}, text: 'outside'}]})]) { + const {host, request} = await fixture(t, {resolve: (_request, selected) => mutate(selected)}); + await assert.rejects(host.resolveCompletion((await request()).data[0].resolveToken), hasCode('invalid_contract')); host.dispose(); + } +}); + +test('stale token and preview cannot cross hosts, document replacement, unregister or deactivation', async t => { + for (const action of ['edit', 'restore', 'unregister', 'deactivate', 'dispose', 'release']) { + const {host, registration, request} = await fixture(t); const list = await request(), token = list.data[0].resolveToken; + const other = createPluginHost({grants: ['document.read', 'language.provide']}); t.after(() => other.dispose()); + await assert.rejects(other.resolveCompletion(token), hasCode('stale_snapshot')); + if (action === 'edit' || action === 'restore') {host.setDocument({...snapshot, modelVersion: 2, text: 'new'}); if (action === 'restore') host.setDocument({...snapshot, modelVersion: 3});} + if (action === 'unregister') registration.dispose(); + if (action === 'deactivate') host.deactivate(manifest.id); + if (action === 'dispose') host.dispose(); + if (action === 'release') host.releaseCompletion(list); + await assert.rejects(host.resolveCompletion(token), hasCode(action === 'dispose' ? 'disposed' : 'stale_snapshot')); + assert.throws(() => host.prepareCompletion(list), error => ['disposed', 'stale_snapshot'].includes(error.code)); + } +}); + +test('tokens expire and released completion lists return bounded retention capacity', async t => { + let now = Date.now(); t.mock.method(Date, 'now', () => now); + const {host, request} = await fixture(t, {provide: req => createLanguageResult(req, Array.from({length: LANGUAGE_LIMITS.resolveTokens}, (_, n) => ({label: 'item-' + n, insertText: '', resolveData: {n}})))}); + const first = await request(); await assert.rejects(request(), hasCode('budget_exceeded')); + host.releaseCompletion(first); const second = await request(); + now += LANGUAGE_LIMITS.resolveTtlMs; await assert.rejects(host.resolveCompletion(second.data[0].resolveToken), hasCode('stale_snapshot')); + assert.equal((await request()).data.length, LANGUAGE_LIMITS.resolveTokens); +}); + +test('ignored resolve cancellation retains real provider slots until settlement and discards late data', async t => { + const finish = deferred(), starts = []; let calls = 0; + const {host, request} = await fixture(t, {resolve: async (_req, selected, {signal}) => {calls++; starts.push(signal); await finish.promise; return selected;}}); + try { + for (let n = 0; n < LANGUAGE_LIMITS.resolvePending; n++) await assert.rejects(host.resolveCompletion((await request()).data[0].resolveToken, {timeoutMs: 5}), hasCode('budget_exceeded')); + assert.equal(calls, 4); assert.ok(starts.every(signal => signal.aborted)); + await assert.rejects(host.resolveCompletion((await request()).data[0].resolveToken), hasCode('budget_exceeded')); + assert.equal(calls, 4); + } finally {finish.resolve();} + await new Promise(resolve => setImmediate(resolve)); + assert.equal((await host.resolveCompletion((await request()).data[0].resolveToken)).data[0].label, 'sum'); +}); + +test('capabilities and grants are explicit; old literal completions and provider classes remain supported', async t => { + assert.throws(() => parseManifest({...manifest, capabilities: ['completion-resolve']}), hasCode('invalid_contract')); + await assert.rejects(fixture(t, {capabilities: ['completion']}), hasCode('permission_denied')); + const noSnippet = await fixture(t, {capabilities: ['completion', 'completion-resolve']}); + await assert.rejects(noSnippet.request(), hasCode('permission_denied')); + const literal = await fixture(t, {resolve: null, capabilities: ['completion'], provide: request => createLanguageResult(request, [{label: 'literal', insertText: '$NAME text'}])}); + assert.equal(literal.host.prepareCompletion(await literal.request()).content, snapshot.text + '$NAME text'); + assert.deepEqual(literal.host.prepareCompletion(await literal.request()).tabstops, []); + assert.equal(parseCompletionItem({label: '😀'.repeat(256), insertText: '😀'.repeat(16_384)}).insertText.length, 32_768); + class Provider {provide(request) {return createLanguageResult(request, {text: 'class'});}} + const registry = createLanguageRegistry('hover'); t.after(() => registry.dispose()); registry.register('class', {languages: ['dds-demo']}, new Provider()); + const req = {protocolVersion: 1, requestId: 'class', scope: {projectId: 'p', sessionId: 's'}, snapshot, position, kind: 'hover'}; + assert.equal((await registry.request(req)).data.text, 'class'); +}); + +test('data validators do not execute getters and final insertion size is bounded', async t => { + let accessed = false; + assert.throws(() => parseCompletionItem({label: 'bad', get insertText() {accessed = true; return '';}}), hasCode('invalid_contract')); assert.equal(accessed, false); + const {host} = await fixture(t, {resolve: null, capabilities: ['completion'], provide: request => createLanguageResult(request, [{label: 'grow', insertText: 'x'}])}); + host.setDocument({...snapshot, modelVersion: 2, text: 'a'.repeat(LIMITS.documentBytes)}); + await assert.rejects(host.requestLanguage('completion', {position: {line: 0, character: LIMITS.documentBytes}}), hasCode('provider_failed')); +}); + +test('signature requests and resolver results are discarded after cancellation or a document change', async t => { + const finish = deferred(), started = deferred(); + const {host, request} = await fixture(t, {resolve: async (_request, selected) => {started.resolve(); await finish.promise; return selected;}}); + assert.deepEqual((await host.requestLanguage('signature-help', {position, context: {triggerKind: 'character', triggerCharacter: ',', isRetrigger: true, activeParameter: 0}})).data, signature); + const token = (await request()).data[0].resolveToken; + const pending = host.resolveCompletion(token); const rejected = assert.rejects(pending, hasCode('stale_snapshot')); + await started.promise; + host.setDocument({...snapshot, modelVersion: 2, text: snapshot.text + 'm'}); + await rejected; finish.resolve(); + const registry = createLanguageRegistry('signature-help'); t.after(() => registry.dispose()); + const blocked = deferred(); registry.register('slow', {languages: ['dds-demo']}, {provide: async req => {await blocked.promise; return createLanguageResult(req, signature);}}); + const controller = new AbortController(); + const result = registry.request({protocolVersion: 1, requestId: 'signature', scope: {projectId: 'p', sessionId: 's'}, snapshot, position, kind: 'signature-help'}, {signal: controller.signal}); + const cancelled = assert.rejects(result, hasCode('cancelled')); + await Promise.resolve(); controller.abort(); await cancelled; blocked.resolve(); +}); + +test('ordinary language requests that ignore cancellation retain the real concurrency budget', async t => { + const registry = createLanguageRegistry('hover'); t.after(() => registry.dispose()); + const finish = deferred(), ready = deferred(); let calls = 0; + registry.register('slow', {languages: ['dds-demo']}, {provide: async req => {if (++calls === LIMITS.maxPendingRequests) ready.resolve(); await finish.promise; return createLanguageResult(req, {text: 'late'});}}); + const req = {protocolVersion: 1, requestId: 'pending', scope: {projectId: 'p', sessionId: 's'}, snapshot, position, kind: 'hover'}; + const controllers = Array.from({length: LIMITS.maxPendingRequests}, () => new AbortController()); + const rejected = controllers.map(controller => assert.rejects(registry.request(req, {signal: controller.signal}), hasCode('cancelled'))); + try { + await ready.promise; controllers.forEach(controller => controller.abort()); await Promise.all(rejected); + await assert.rejects(registry.request(req), hasCode('budget_exceeded')); + assert.equal(calls, LIMITS.maxPendingRequests); + } finally {finish.resolve();} + await new Promise(resolve => setImmediate(resolve)); + assert.equal((await registry.request(req)).data.text, 'late'); +}); + +test('tokens retain the original provider, enforce memory budget and cannot be supplied by a provider', async t => { + const registry = createLanguageRegistry('completion'); t.after(() => registry.dispose()); + const req = {protocolVersion: 1, requestId: 'select', scope: {projectId: 'p', sessionId: 's'}, snapshot, position, kind: 'completion'}; + registry.register('first', {languages: ['dds-demo']}, {provide: r => createLanguageResult(r, [item]), resolve: (_r, i) => ({...i, detail: 'original'})}, {resolve: true, snippets: true}); + const selected = await registry.request(req); + registry.register('higher', {languages: ['dds-demo'], priority: 1}, {provide: r => createLanguageResult(r, [{label: 'other', insertText: ''}])}); + assert.equal((await registry.resolve(selected.data[0].resolveToken)).data[0].detail, 'original'); + const forged = await fixture(t, {provide: r => createLanguageResult(r, [{label: 'forged', insertText: '', resolveToken: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'}])}); + await assert.rejects(forged.request(), hasCode('invalid_contract')); + const memory = await fixture(t, {provide: r => createLanguageResult(r, Array.from({length: 20}, () => ({label: 'retain', insertText: '', resolveData: 1})))}); + memory.host.setDocument({...snapshot, modelVersion: 2, text: 'x'.repeat(220_000)}); + await assert.rejects(memory.host.requestLanguage('completion', {position: {line: 0, character: 0}}), hasCode('budget_exceeded')); + assert.equal(SDK_VERSION, JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')).version); +}); diff --git a/tests/language.test.mjs b/tests/language.test.mjs index 7b3723f..1ee6c5f 100644 --- a/tests/language.test.mjs +++ b/tests/language.test.mjs @@ -17,7 +17,7 @@ async function hostFor(kind, provide, extra) { await host.activate(plugin(kind, provide, extra)); host.setDocument(document); return host; } -for (const kind of LANGUAGE_FEATURES) { +for (const kind of Object.keys(payload)) { test(`${kind}: immutable scoped contracts and host result`, async () => { const req = parseLanguageRequest(JSON.stringify(request(kind))); const result = createLanguageResult(req, payload[kind]); diff --git a/tests/publishing.test.mjs b/tests/publishing.test.mjs index bce6b8b..6efc3c3 100644 --- a/tests/publishing.test.mjs +++ b/tests/publishing.test.mjs @@ -72,7 +72,7 @@ test('open and proprietary packages use explicit files, never execute code, and const npmMetadata = JSON.parse(archive.get('package/package.json')); assert.equal(npmMetadata.name, '@example/hello'); assert.equal(npmMetadata.exports, './plugin.mjs'); - assert.equal(npmMetadata.peerDependencies['@altifigence/dds-plugin-sdk'], '>=0.10.0 <0.11.0'); + assert.equal(npmMetadata.peerDependencies['@altifigence/dds-plugin-sdk'], '>=0.11.0 <0.12.0'); assert.equal(npmMetadata.scripts, undefined); assert.equal(JSON.parse(await readFile(packed.metadataPath, 'utf8')).artifact.sha256, packed.artifact.sha256); } diff --git a/tests/types/language-assistance.mts b/tests/types/language-assistance.mts new file mode 100644 index 0000000..e685504 --- /dev/null +++ b/tests/types/language-assistance.mts @@ -0,0 +1,27 @@ +import {createPluginHost, createLanguageRegistry, createLanguageResult, parseSnippet, parseSignatureHelp, type CompletionInsertion, type CompletionItem} from '@altifigence/dds-plugin-sdk'; +const host = createPluginHost({grants: ['document.read', 'language.provide']}); +const supported: boolean = host.languageCapabilities().completionResolve; +const signature = await host.requestLanguage('signature-help', {position: {line: 0, character: 0}, context: {triggerKind: 'character', triggerCharacter: '(', isRetrigger: false}}); +const active: number | null | undefined = signature.data?.activeParameter; +const completion = await host.requestLanguage('completion', {position: {line: 0, character: 0}, context: {triggerKind: 'incomplete'}}); +const resolved = await host.resolveCompletion(completion.data[0]!.resolveToken!); +const preview: CompletionInsertion = host.prepareCompletion(resolved); +const offset: number | undefined = parseSnippet('${1:hello}$0').tabstops[0]?.ranges[0]?.start; +const registry = createLanguageRegistry('completion'); +registry.register('typed', {languages: ['teaching']}, { + provide: request => createLanguageResult(request, [{label: 'sum', insertText: '$0', insertTextFormat: 'snippet', resolveData: {id: 'sum'}}]), + resolve(_request, selected, {signal}) {signal.throwIfAborted(); return {...selected, documentation: 'plain text'};}, +}, {resolve: true, snippets: true}); +host.releaseCompletion(completion); +parseSignatureHelp({signatures: [{label: 'run()', parameters: []}], activeSignature: 0, activeParameter: null}); +// @ts-expect-error Signature help needs a document position. +host.requestLanguage('signature-help', {}); +// @ts-expect-error Completion context does not accept signature retrigger metadata. +host.requestLanguage('completion', {position: {line: 0, character: 0}, context: {triggerKind: 'invoked', isRetrigger: false}}); +// @ts-expect-error Hover has no trigger context. +host.requestLanguage('hover', {position: {line: 0, character: 0}, context: {triggerKind: 'invoked'}}); +// @ts-expect-error Snippets are a fixed grammar, with no command insertion format. +const executable: CompletionItem = {label: 'wrong', insertText: '', insertTextFormat: 'command'}; +// @ts-expect-error Completion previews are immutable. +preview.content = ''; +void supported; void active; void offset; void executable; From 2f31e10867925c441d76a36474cbc36b82be503f Mon Sep 17 00:00:00 2001 From: hkimw <54717101+hkimw@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:22:43 +0900 Subject: [PATCH 2/4] feat: add reviewed workspace edit plans and interrupted apply recovery --- CHANGELOG.md | 4 + PUBLIC_SURFACE.json | 15 + README.md | 2 + docs/COMPATIBILITY.md | 3 + docs/WORKSPACE_EDITS.md | 156 ++++++ examples/workspace-edits/LICENSE | 202 +++++++ examples/workspace-edits/child.mjs | 24 + examples/workspace-edits/plugin.mjs | 36 ++ examples/workspace-edits/run.mjs | 70 +++ package.json | 11 +- schemas/language-capabilities.schema.json | 4 +- schemas/language-request.schema.json | 216 ++++++++ schemas/language-result.schema.json | 477 ++++++++++++++++ schemas/manifest.schema.json | 4 +- schemas/workspace-edit-preview.schema.json | 519 ++++++++++++++++++ schemas/workspace-edit-receipt.schema.json | 159 ++++++ schemas/workspace-edit-record.schema.json | 598 +++++++++++++++++++++ schemas/workspace-edit.schema.json | 243 +++++++++ scripts/pack-check.mjs | 3 + src/contracts.mjs | 33 +- src/host.mjs | 2 +- src/index.d.mts | 9 +- src/schemas.mjs | 8 +- src/workspace-edit-contracts.mjs | 158 ++++++ src/workspace-edit-schemas.mjs | 31 ++ src/workspace-edits-node.d.mts | 8 + src/workspace-edits-node.mjs | 207 +++++++ src/workspace-edits.d.mts | 65 +++ src/workspace-edits.mjs | 170 ++++++ tests/types/workspace-edits.mts | 27 + tests/workspace-edits.test.mjs | 199 +++++++ 31 files changed, 3649 insertions(+), 14 deletions(-) create mode 100644 docs/WORKSPACE_EDITS.md create mode 100644 examples/workspace-edits/LICENSE create mode 100644 examples/workspace-edits/child.mjs create mode 100644 examples/workspace-edits/plugin.mjs create mode 100644 examples/workspace-edits/run.mjs create mode 100644 schemas/workspace-edit-preview.schema.json create mode 100644 schemas/workspace-edit-receipt.schema.json create mode 100644 schemas/workspace-edit-record.schema.json create mode 100644 schemas/workspace-edit.schema.json create mode 100644 src/workspace-edit-contracts.mjs create mode 100644 src/workspace-edit-schemas.mjs create mode 100644 src/workspace-edits-node.d.mts create mode 100644 src/workspace-edits-node.mjs create mode 100644 src/workspace-edits.d.mts create mode 100644 src/workspace-edits.mjs create mode 100644 tests/types/workspace-edits.mts create mode 100644 tests/workspace-edits.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a639e12..615064d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.11.0 — Unreleased +- Add prepare-rename/rename providers, reviewed multi-file edit plans, reproducible + previews, per-file CAS, durable intent/receipt journals and observational recovery. + Add independently reviewed inverse proposals and killed-process HTTP examples. + - Add bounded signature-help context/results, one-use completion resolve tokens, optional snippet grammar and pure insertion previews with UTF-16/CRLF validation. - Keep actual provider work within request/resolver limits when cancellation is diff --git a/PUBLIC_SURFACE.json b/PUBLIC_SURFACE.json index 5c9b9be..82befc3 100644 --- a/PUBLIC_SURFACE.json +++ b/PUBLIC_SURFACE.json @@ -36,6 +36,7 @@ "docs/VERIFYING.md", "docs/workspace-protocol.md", "docs/WORKSPACES.md", + "docs/WORKSPACE_EDITS.md", "examples/binary-artifacts/run.mjs", "examples/browser-artifacts/browser.html", "examples/browser-artifacts/browser.mjs", @@ -98,6 +99,10 @@ "examples/user-workspace/server.mjs", "examples/user-workspace/tool.mjs", "examples/workspace-observation/run.mjs", + "examples/workspace-edits/LICENSE", + "examples/workspace-edits/plugin.mjs", + "examples/workspace-edits/child.mjs", + "examples/workspace-edits/run.mjs", "LICENSE", "NOTICE", "package.json", @@ -160,6 +165,10 @@ "schemas/workspace-file-revision.schema.json", "schemas/workspace-project-capabilities.schema.json", "schemas/workspace-project-poll.schema.json", + "schemas/workspace-edit.schema.json", + "schemas/workspace-edit-preview.schema.json", + "schemas/workspace-edit-record.schema.json", + "schemas/workspace-edit-receipt.schema.json", "SECURITY.md", "src/artifact-download-node.d.mts", "src/artifact-download-node.mjs", @@ -270,6 +279,12 @@ "src/workspace-upload-client.mjs", "src/workspace-upload-node.mjs", "src/workspace-values.mjs", + "src/workspace-edit-contracts.mjs", + "src/workspace-edit-schemas.mjs", + "src/workspace-edits.mjs", + "src/workspace-edits.d.mts", + "src/workspace-edits-node.mjs", + "src/workspace-edits-node.d.mts", "templates/LICENSE.proprietary.example.txt", "templates/publisher-disclosure.example.json" ] diff --git a/README.md b/README.md index 59d0909..db3b0f3 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ your own workspace and tools with the Node.js host and HTTP client. The SDK has Development toward 0.11 adds [signature help and completion assistance](docs/LANGUAGE_ASSISTANCE.md), including lazy item resolution, limited snippets and pure insertion previews. Run `npm run example:assistance` for the nested-call and Unicode example. +[Reviewed workspace edits](docs/WORKSPACE_EDITS.md) add rename proposals, multi-file +previews, conditional application and readback after interrupted operations. The 0.10 line adds [browser artifact streaming](docs/BROWSER_ARTIFACTS.md) with bounded sinks, incremental whole-file hashes, explicit storage readback and diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index e92a0d4..c0a74ed 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -8,6 +8,9 @@ available. Older SDKs reject unknown manifest capabilities; these extensions do change the workspace HTTP protocol. New plugin archives declare `>=0.11.0 <0.12.0`; verification retains the exact previously published 0.3.x through 0.10.x metadata. See [language assistance](LANGUAGE_ASSISTANCE.md) for bounds and runtime checks. +`prepare-rename`/`rename` add proposals; [workspace edits](WORKSPACE_EDITS.md) preview +and apply them through existing file methods with a separate current approval and +journal. The Node journal is optional, operator-owned storage outside the workspace. | Surface | Supported scope | | --- | --- | diff --git a/docs/WORKSPACE_EDITS.md b/docs/WORKSPACE_EDITS.md new file mode 100644 index 0000000..bd149a5 --- /dev/null +++ b/docs/WORKSPACE_EDITS.md @@ -0,0 +1,156 @@ +# Reviewed multi-file workspace edits + +SDK 0.11 adds optional `prepare-rename` and `rename` language providers plus a +separate `@altifigence/dds-plugin-sdk/workspace-edits` client library. Providers +propose data. The caller previews the proposal, reviews its exact ID and digest, +and supplies current authorization before any workspace mutation. Existing +workspace HTTP protocol v1 read/write/rename/remove methods perform the file I/O. + +Run `npm run example:workspace-edits`, or copy `examples/workspace-edits` from the +installed package and run its `run.mjs`. The example reads two explicitly selected +files, prepares a rename, shows an external-edit conflict, applies a newly reviewed +proposal, then kills a separate apply process at two checkpoints and reads back +exactly which files match the proposed changes. It uses disposable directories. + +## Rename providers + +Declare `prepare-rename` and/or `rename` in manifest v2, with `document.read` and +`language.provide` permissions granted by the host. Both requests require a document +position. `rename` also requires a nonempty `newName` of at most 256 UTF-16 units, +without control characters; each language decides whether that name is valid syntax. + +`prepare-rename` returns `null` or `{range, placeholder}`. Its nonempty UTF-16 range +must contain the request position without splitting a surrogate or CRLF. `rename` +returns `null` or a `WorkspaceEdit`. Existing request identity, provider priority, +cancellation, pending limits and stale-result checks apply. Neither capability grants +file reads or writes. Supply only snapshots obtained through separately authorized +workspace reads. The teaching example passes such snapshots into a provider. + +## Proposals, previews and approval + +A proposal is `{formatVersion: 1, id, title, changes}` with a new UUID and one or +more changes. Each change has a relative `path`, optional plain-text `reason`, and: + +| Kind | Required data | Conditions | +| --- | --- | --- | +| `edit` | `baseRevision`, `edits: [{range, text}]` | Revision must match; ranges refer to the original content | +| `create` | `content` | Destination must be absent | +| `move` | `newPath`, `baseRevision` | Source must match; destination must be absent | +| `delete` | `baseRevision` | Source must match | + +Revisions are whole-content SHA-256 hashes. Paths must be disjoint, including move +destinations, with case-insensitive duplicate and ancestor/descendant rejection on +all platforms. Directory changes and dependent operation chains are unsupported. +Protected paths, traversal and link checks retain the existing workspace rules. +Content-identical edits are reported as `no_change` and cannot be applied. + +```js +import {previewWorkspaceEdit, applyWorkspaceEdit} + from '@altifigence/dds-plugin-sdk/workspace-edits'; + +const preview = await previewWorkspaceEdit(client, proposal); +// Present preview.steps, conflicts and requiredCapabilities to the user. +// Preserve the exact planId and digest approved by that review. +const receipt = await applyWorkspaceEdit(client, preview, { + reviewed: approvedReview, + journal, + authorize: (request, {signal}) => currentHostPolicy(request, signal), +}); +``` + +`client` is an explicitly connected authorized workspace client. `approvedReview` +contains the reviewed `{planId, digest}`; `currentHostPolicy` is a trusted host policy +that returns exactly `true` only while that review and required current capabilities +remain valid. Installing a plugin or receiving its suggestion is not approval. +The policy is checked before preflight and each change, including after the durable +intent checkpoint. Read-only hosts cannot apply. Moves/deletes require `manage`; +all changes require `write`. Existing server checks remain authoritative. + +Previews contain full before/after file snapshots, required capabilities, conflicts +and one replacement hunk per affected path (`start`, `deleteCount`, `insertText`, in +UTF-16). Source hashes and all derived after content/diffs are verified. A canonical +SHA-256 binds the complete preview, proposal and workspace generation. A deserialized +preview is accepted only when it reproduces exactly; a changed field invalidates it. + +Apply preflights **every** affected file before starting, then uses conditional +operations and checks each file again immediately before mutation. External edits +between those checks still face the existing per-file CAS. The existing local +filesystem adapter is a cooperative host, not protection against an adversarial +local process racing native filesystem calls. Multiple files are not an OS-atomic +transaction. One helper apply may run per client/journal port at a time. + +## Journals and interrupted operations + +`WorkspaceEditJournal` is a trusted host port with `begin`, `write` and `read`. +`begin` must reject a reused plan ID. Each method must persist before resolving. +The helper reads back each checkpoint before continuing. It records the complete +reviewed plan before any file change, records `intent` before each operation, and +records a verified applied receipt afterward. Journal failure stops further writes. +There is no automatic retry of an interrupted plan. + +The Node adapter comes from `@altifigence/dds-plugin-sdk/workspace-edits-node`: + +```js +const journal = await createNodeWorkspaceEditJournal({ + directory: selectedJournalDirectory, + workspaceRoot: selectedWorkspaceRoot, + workspaceId: client.binding.workspace.id, +}); +// ...use this operator-owned journal... +await journal.close(); +``` + +Both directory paths are operator-selected absolute paths. Journal storage must be +outside the workspace, with neither containing the other. The adapter binds the +logical workspace ID and canonical local directory identity. It uses a single-writer +lock, private regular files, checksum envelopes, file `fsync` and atomic checkpoint +rename. It rejects symlinks/hardlinks and detects changed roots. On Windows, +`directoryFsync` is false; do not infer power-loss durability for directory metadata. +The example verifies process termination, not power failure or network-filesystem +locking. Same-user malicious filesystem access is outside this trusted host boundary. + +After a process dies, explicitly reopen with `recoverStaleLock: true`. Recovery +reclaims a lock only for a demonstrably dead PID on the same machine; a live or +uncertain owner is preserved. Reopening grants readback, not permission to resume +the old plan. Corrupt or incomplete records remain visible through `list()` as +unavailable; they are never repaired into executable plans. There is no automatic +eviction. Operators own retention, backup and disposal of this sensitive source data. + +`recoverWorkspaceEdit(client, journal, planId)` reads current files with the caller's +current connection. It may use a new generation of the **same** workspace, but apply +rejects an old-generation preview. For each step it reports `applied`, `not-applied`, +`conflicted` or `unknown`. These states compare current content with recorded before +and after states; they do not prove the historical absence of intermediate edits. +A durable applied receipt followed by original bytes is `conflicted`. Revoked read +access, unavailable files and uncertain observations yield `unknown`. + +A lost response stops execution even if readback proves the change applied. Inspect +both `status` and `executionStopped`/`errorCode`/`journalError`. Cancellation and +timeouts do not prove that a remote operation stopped; recover again after it has +settled. Recovery never replays changes. `createWorkspaceCompensation` produces a +new inverse proposal only for steps currently reported applied. Preview and review +it anew: stale revisions, occupied move destinations or new files can block recovery +without overwriting someone else's work. File modes/timestamps are not restored. + +## Bounds and compatibility + +Limits are 32 changes, 500 edits per file, 1,000 edits total, 1 MiB serialized +proposal, 8 MiB preview and the existing 256 KiB per-file UTF-8 limit. Node journals +hold at most 64 plans and 64 MiB; incomplete checkpoint files count toward capacity. +Request/authority timeout defaults to 5 seconds, capped at 30 seconds. Apply has no +single total deadline; an abort signal can cancel the sequence. Trusted journal +ports must complete or fail their own persistence operations. Old hosts that expose +the original file methods remain usable; no new HTTP method is required. + +## 한국어 + +이름 변경 제공자는 상대 경로와 기준 revision이 포함된 수정안만 제안합니다. +별도 호스트가 현재 승인 연결로 파일을 읽어 미리보기·변경 내용·충돌을 확인하고, +검토한 계획 ID/해시와 현재 쓰기/관리 권한을 확인한 뒤 적용합니다. 한 파일이라도 +사전검사에서 달라졌으면 시작하지 않으며, 적용 도중 충돌하면 이후 변경을 멈춥니다. + +여러 파일 전체의 원자성을 약속하지 않습니다. 작업 의도와 결과를 기록하고, +응답 유실·프로세스 종료 후 현재 파일을 읽어 적용/미적용/충돌/불확실을 구분합니다. +기존 계획을 자동 재실행하지 않으며, 되돌리기도 새로운 수정안의 검토·권한·CAS +검사를 거칩니다. SDK 예제는 실제 자식 프로세스 종료를 검증하며 전원 장애나 +DDS 제품 에디터 통합의 검증을 의미하지 않습니다. diff --git a/examples/workspace-edits/LICENSE b/examples/workspace-edits/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/examples/workspace-edits/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/examples/workspace-edits/child.mjs b/examples/workspace-edits/child.mjs new file mode 100644 index 0000000..a80935f --- /dev/null +++ b/examples/workspace-edits/child.mjs @@ -0,0 +1,24 @@ +import {createWorkspaceClient} from '@altifigence/dds-plugin-sdk/workspace-client'; +import {applyWorkspaceEdit} from '@altifigence/dds-plugin-sdk/workspace-edits'; +import {createNodeWorkspaceEditJournal} from '@altifigence/dds-plugin-sdk/workspace-edits-node'; + +process.once('message', async input => { + let journal, client; + const checkpoint = async phase => {process.send({phase, planId: input.preview.planId}); await new Promise(() => {});}; + try { + client = createWorkspaceClient({url: input.url, token: input.token}); await client.connect(); + journal = await createNodeWorkspaceEditJournal({directory: input.directory, workspaceRoot: input.root, workspaceId: input.workspaceId}); + const journalPort = {...journal, async write(record) { + await journal.write(record); + if (input.pauseAt === 'intent' && record.steps[0].state === 'intent') await checkpoint('intent'); + }}; + const clientPort = {...client, async writeFile(...args) { + const result = await client.writeFile(...args); + if (input.pauseAt === 'applied') await checkpoint('applied'); + return result; + }}; + const receipt = await applyWorkspaceEdit(clientPort, input.preview, {journal: journalPort, reviewed: {planId: input.preview.planId, digest: input.preview.digest}, authorize: () => true}); + process.send({unexpectedCompletion: receipt.status}); + } catch (error) {process.send({errorCode: error?.code ?? 'provider_failed'});} + finally {client?.dispose(); await journal?.close(); process.disconnect();} +}); diff --git a/examples/workspace-edits/plugin.mjs b/examples/workspace-edits/plugin.mjs new file mode 100644 index 0000000..d0a8ba8 --- /dev/null +++ b/examples/workspace-edits/plugin.mjs @@ -0,0 +1,36 @@ +import {definePlugin, createLanguageResult} from '@altifigence/dds-plugin-sdk'; + +function symbolAt(request) { + const line = request.snapshot.text.split(/\r\n|\r|\n/)[request.position.line]; + for (const match of line.matchAll(/[A-Za-z_][A-Za-z0-9_]*/g)) if (match.index <= request.position.character && request.position.character <= match.index + match[0].length) { + return {placeholder: match[0], range: {start: {line: request.position.line, character: match.index}, end: {line: request.position.line, character: match.index + match[0].length}}}; + } + return null; +} + +// The caller explicitly reads and supplies these allowed file snapshots. The +// provider itself only proposes edits; it receives no write or journal port. +export function teachingRenamePlugin(files) { + return definePlugin({ + manifestVersion: 2, id: 'teaching-rename', name: 'Teaching Rename', publisher: 'example', version: '1.0.0', + protocolVersion: 1, entry: './plugin.mjs', runtime: 'ui', supportedHosts: ['test-host'], + capabilities: ['prepare-rename', 'rename'], permissions: ['document.read', 'language.provide'], + license: 'Apache-2.0', source: {visibility: 'open', licenseFile: 'LICENSE'}, + }, context => { + context.registerLanguageProvider('prepare-rename', {languages: ['teaching']}, {provide: request => createLanguageResult(request, symbolAt(request))}); + context.registerLanguageProvider('rename', {languages: ['teaching']}, {provide(request, {signal}) { + signal.throwIfAborted(); + const symbol = symbolAt(request); + if (!symbol || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(request.newName)) return createLanguageResult(request, null); + const changes = []; + for (const file of files) { + const edits = []; + file.content.split(/\r\n|\r|\n/).forEach((line, number) => { + for (const match of line.matchAll(/[A-Za-z_][A-Za-z0-9_]*/g)) if (match[0] === symbol.placeholder) edits.push({range: {start: {line: number, character: match.index}, end: {line: number, character: match.index + match[0].length}}, text: request.newName}); + }); + if (edits.length) changes.push({kind: 'edit', path: file.path, baseRevision: file.revision, edits, reason: 'Rename the selected teaching-language symbol'}); + } + return createLanguageResult(request, changes.length ? {formatVersion: 1, id: crypto.randomUUID(), title: `Rename ${symbol.placeholder} to ${request.newName}`, changes} : null); + }}); + }); +} diff --git a/examples/workspace-edits/run.mjs b/examples/workspace-edits/run.mjs new file mode 100644 index 0000000..25e79a3 --- /dev/null +++ b/examples/workspace-edits/run.mjs @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import {randomUUID} from 'node:crypto'; +import {fork} from 'node:child_process'; +import {fileURLToPath} from 'node:url'; +import {createPluginHost} from '@altifigence/dds-plugin-sdk'; +import {createWorkspaceServer} from '@altifigence/dds-plugin-sdk/workspace-node'; +import {createWorkspaceClient} from '@altifigence/dds-plugin-sdk/workspace-client'; +import {previewWorkspaceEdit, applyWorkspaceEdit, recoverWorkspaceEdit} from '@altifigence/dds-plugin-sdk/workspace-edits'; +import {createNodeWorkspaceEditJournal} from '@altifigence/dds-plugin-sdk/workspace-edits-node'; +import {teachingRenamePlugin} from './plugin.mjs'; + +const parent = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'dds-edit-example-'))), root = path.join(parent, 'workspace'), workspaceId = randomUUID(); +const token = 'disposable-workspace-edit-example-0123456789', children = [], journals = []; +let server, client, language; +try { + await fs.mkdir(root); await fs.writeFile(path.join(root, 'first.dds'), 'alpha 😀\r\nalpha'); await fs.writeFile(path.join(root, 'second.dds'), 'alpha'); + server = await createWorkspaceServer({root, workspaceId, token, notice: {id: 'edit-example', version: '1', text: 'Disposable workspace edit example'}}); + client = createWorkspaceClient({url: server.url, token}); await client.connect(); + async function suggest(name) { + language?.dispose(); language = createPluginHost({grants: ['document.read', 'language.provide']}); + const files = await Promise.all(['first.dds', 'second.dds'].map(file => client.readFile(file))); + await language.activate(teachingRenamePlugin(files)); + language.setDocument({uri: 'memory:///first.dds', languageId: 'teaching', modelVersion: 1, workspaceRevision: files[0].revision, text: files[0].content}); + const position = {line: 0, character: 2}, preparation = await language.requestLanguage('prepare-rename', {position}); + assert.ok(preparation.data.placeholder); + return (await language.requestLanguage('rename', {position, newName: name})).data; + } + const initial = await suggest('beta'), preview = await previewWorkspaceEdit(client, initial); + await fs.writeFile(path.join(root, 'second.dds'), 'alpha // external edit'); + const journal = await createNodeWorkspaceEditJournal({directory: path.join(parent, 'journal'), workspaceRoot: root, workspaceId}); journals.push(journal); + await assert.rejects(applyWorkspaceEdit(client, preview, {journal, reviewed: {planId: preview.planId, digest: preview.digest}, authorize: () => true}), {code: 'conflict'}); + assert.equal((await client.readFile('first.dds')).content, 'alpha 😀\r\nalpha'); + const refreshed = await previewWorkspaceEdit(client, await suggest('beta')); + assert.equal((await applyWorkspaceEdit(client, refreshed, {journal, reviewed: {planId: refreshed.planId, digest: refreshed.digest}, authorize: () => true})).status, 'completed'); + assert.equal((await client.readFile('second.dds')).content, 'beta // external edit'); + await journal.close(); + + // Kill a separate apply process before any file write and after its first write, + // then inspect durable intent and current files without replaying that plan. + for (const pauseAt of ['intent', 'applied']) { + const recoveryPreview = await previewWorkspaceEdit(client, await suggest(pauseAt === 'intent' ? 'gamma' : 'delta')); + const directory = path.join(parent, 'journal-' + pauseAt); + const child = fork(fileURLToPath(new URL('./child.mjs', import.meta.url)), [], {stdio: ['ignore', 'ignore', 'pipe', 'ipc'], windowsHide: true}); children.push(child); + let stderr = ''; child.stderr.on('data', chunk => {stderr = (stderr + chunk).slice(-2048);}); + const exit = new Promise(resolve => child.once('exit', (code, signal) => resolve({code, signal}))); + const checkpoint = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(Error('Apply child checkpoint timed out')), 20_000); + child.once('message', message => {clearTimeout(timer); resolve(message);}); + child.once('exit', () => {clearTimeout(timer); reject(Error('Apply child exited before checkpoint: ' + stderr));}); child.once('error', reject); + }); + child.send({url: server.url, token, root, workspaceId, directory, preview: recoveryPreview, pauseAt}); + assert.deepEqual(await checkpoint, {phase: pauseAt, planId: recoveryPreview.planId}); child.kill('SIGKILL'); await exit; + const recoveredJournal = await createNodeWorkspaceEditJournal({directory, workspaceRoot: root, workspaceId, recoverStaleLock: true}); journals.push(recoveredJournal); + const recovered = await recoverWorkspaceEdit(client, recoveredJournal, recoveryPreview.planId); + assert.equal(recovered.steps[0].state, pauseAt === 'intent' ? 'not-applied' : 'applied'); + assert.equal(recovered.steps[1].state, 'not-applied'); + assert.equal((await recoveredJournal.read(recoveryPreview.planId)).steps[0].state, 'intent'); + await recoveredJournal.close(); + } + assert.equal((await client.readFile('first.dds')).content, 'delta 😀\r\ndelta'); + assert.equal((await client.readFile('second.dds')).content, 'beta // external edit'); + console.log('rename preview, external-edit conflict, reviewed CAS and killed-process recovery verified'); +} finally { + for (const child of children) if (child.exitCode === null && child.signalCode === null) {const exit = new Promise(resolve => child.once('exit', resolve)); child.kill('SIGKILL'); await exit;} + for (const journal of journals) await journal.close(); language?.dispose(); client?.dispose(); await server?.close(); + assert.equal(path.dirname(parent), await fs.realpath(os.tmpdir())); assert.ok(path.basename(parent).startsWith('dds-edit-example-')); await fs.rm(parent, {recursive: true}); +} diff --git a/package.json b/package.json index 192439a..40e0417 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,14 @@ "types": "./src/devtools.d.mts", "import": "./src/devtools.mjs" }, + "./workspace-edits": { + "types": "./src/workspace-edits.d.mts", + "import": "./src/workspace-edits.mjs" + }, + "./workspace-edits-node": { + "types": "./src/workspace-edits-node.d.mts", + "import": "./src/workspace-edits-node.mjs" + }, "./package.json": "./package.json", "./project-watch": { "types": "./src/project-watch.d.mts", @@ -181,7 +189,8 @@ "example:transfer-queue-browser": "node examples/transfer-queue/serve.mjs", "benchmark:transfer-queue": "node scripts/benchmark-transfer-queue.mjs", "example:browser-resume": "node examples/browser-resume/serve.mjs", - "example:assistance": "node examples/language-assistance/run.mjs" + "example:assistance": "node examples/language-assistance/run.mjs", + "example:workspace-edits": "node examples/workspace-edits/run.mjs" }, "devDependencies": { "typescript": "5.9.3" diff --git a/schemas/language-capabilities.schema.json b/schemas/language-capabilities.schema.json index e3272a4..15fa063 100644 --- a/schemas/language-capabilities.schema.json +++ b/schemas/language-capabilities.schema.json @@ -14,7 +14,9 @@ "definition", "references", "document-symbols", - "signature-help" + "signature-help", + "prepare-rename", + "rename" ] }, "completionResolve": { diff --git a/schemas/language-request.schema.json b/schemas/language-request.schema.json index 3882d0f..867ba8b 100644 --- a/schemas/language-request.schema.json +++ b/schemas/language-request.schema.json @@ -748,6 +748,222 @@ "position" ], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "protocolVersion": { + "const": 1 + }, + "requestId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "scope": { + "type": "object", + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "sessionId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "projectId", + "sessionId" + ], + "additionalProperties": false + }, + "snapshot": { + "type": "object", + "properties": { + "uri": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "pattern": "^[A-Za-z][A-Za-z0-9+.-]*:[^\\s\\u0000-\\u001f\\u007f]+$" + }, + "languageId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$" + }, + "modelVersion": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "workspaceRevision": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "text": { + "type": "string", + "maxLength": 262144, + "x-maxUtf8Bytes": 262144 + } + }, + "required": [ + "uri", + "languageId", + "modelVersion", + "workspaceRevision", + "text" + ], + "additionalProperties": false + }, + "kind": { + "const": "prepare-rename" + }, + "position": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + } + }, + "required": [ + "protocolVersion", + "requestId", + "scope", + "snapshot", + "kind", + "position" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "protocolVersion": { + "const": 1 + }, + "requestId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "scope": { + "type": "object", + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "sessionId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "projectId", + "sessionId" + ], + "additionalProperties": false + }, + "snapshot": { + "type": "object", + "properties": { + "uri": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "pattern": "^[A-Za-z][A-Za-z0-9+.-]*:[^\\s\\u0000-\\u001f\\u007f]+$" + }, + "languageId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$" + }, + "modelVersion": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "workspaceRevision": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "text": { + "type": "string", + "maxLength": 262144, + "x-maxUtf8Bytes": 262144 + } + }, + "required": [ + "uri", + "languageId", + "modelVersion", + "workspaceRevision", + "text" + ], + "additionalProperties": false + }, + "kind": { + "const": "rename" + }, + "position": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + }, + "newName": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "protocolVersion", + "requestId", + "scope", + "snapshot", + "kind", + "position", + "newName" + ], + "additionalProperties": false } ] } diff --git a/schemas/language-result.schema.json b/schemas/language-result.schema.json index d3af2e3..e162c24 100644 --- a/schemas/language-result.schema.json +++ b/schemas/language-result.schema.json @@ -1093,6 +1093,483 @@ "data" ], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "protocolVersion": { + "const": 1 + }, + "requestId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "scope": { + "type": "object", + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "sessionId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "projectId", + "sessionId" + ], + "additionalProperties": false + }, + "snapshot": { + "type": "object", + "properties": { + "uri": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "pattern": "^[A-Za-z][A-Za-z0-9+.-]*:[^\\s\\u0000-\\u001f\\u007f]+$" + }, + "languageId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$" + }, + "modelVersion": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "workspaceRevision": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "uri", + "languageId", + "modelVersion", + "workspaceRevision" + ], + "additionalProperties": false + }, + "kind": { + "const": "prepare-rename" + }, + "data": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "properties": { + "range": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + }, + "end": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false + }, + "placeholder": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "range", + "placeholder" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "protocolVersion", + "requestId", + "scope", + "snapshot", + "kind", + "data" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "protocolVersion": { + "const": 1 + }, + "requestId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "scope": { + "type": "object", + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "sessionId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "projectId", + "sessionId" + ], + "additionalProperties": false + }, + "snapshot": { + "type": "object", + "properties": { + "uri": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "pattern": "^[A-Za-z][A-Za-z0-9+.-]*:[^\\s\\u0000-\\u001f\\u007f]+$" + }, + "languageId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$" + }, + "modelVersion": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "workspaceRevision": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "uri", + "languageId", + "modelVersion", + "workspaceRevision" + ], + "additionalProperties": false + }, + "kind": { + "const": "rename" + }, + "data": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "properties": { + "formatVersion": { + "const": 1 + }, + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "changes": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "const": "edit" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "baseRevision": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$" + }, + "edits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "range": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + }, + "end": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false + }, + "text": { + "type": "string", + "minLength": 0, + "maxLength": 262144, + "x-maxUtf8Bytes": 262144 + } + }, + "required": [ + "range", + "text" + ], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 500 + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "kind", + "path", + "baseRevision", + "edits" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "const": "create" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "content": { + "type": "string", + "minLength": 0, + "maxLength": 262144, + "x-maxUtf8Bytes": 262144 + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "kind", + "path", + "content" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "const": "move" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "baseRevision": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$" + }, + "newPath": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "kind", + "path", + "baseRevision", + "newPath" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "const": "delete" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "baseRevision": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$" + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "kind", + "path", + "baseRevision" + ], + "additionalProperties": false + } + ] + }, + "minItems": 1, + "maxItems": 32 + } + }, + "required": [ + "formatVersion", + "id", + "title", + "changes" + ], + "additionalProperties": false, + "x-maxUtf8Bytes": 1048576, + "$comment": "At most 1000 edits in all files. Runtime validates paths, scalar strings and edit geometry. Revisions are full content SHA-256. Destinations must be absent, and affected paths cannot be duplicated or nested." + } + ] + } + }, + "required": [ + "protocolVersion", + "requestId", + "scope", + "snapshot", + "kind", + "data" + ], + "additionalProperties": false } ] } diff --git a/schemas/manifest.schema.json b/schemas/manifest.schema.json index f814a46..dd611c1 100644 --- a/schemas/manifest.schema.json +++ b/schemas/manifest.schema.json @@ -187,12 +187,14 @@ "references", "document-symbols", "signature-help", + "prepare-rename", + "rename", "completion-resolve", "completion-snippets" ] }, "minItems": 1, - "maxItems": 10, + "maxItems": 12, "uniqueItems": true }, "permissions": { diff --git a/schemas/workspace-edit-preview.schema.json b/schemas/workspace-edit-preview.schema.json new file mode 100644 index 0000000..fa1c084 --- /dev/null +++ b/schemas/workspace-edit-preview.schema.json @@ -0,0 +1,519 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/workspace-edit-preview.schema.json", + "title": "workspace-edit-preview", + "type": "object", + "properties": { + "formatVersion": { + "const": 1 + }, + "planId": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" + }, + "workspace": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" + }, + "generation": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" + } + }, + "required": [ + "id", + "generation" + ], + "additionalProperties": false + }, + "edit": { + "type": "object", + "properties": { + "formatVersion": { + "const": 1 + }, + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "changes": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "const": "edit" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "baseRevision": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$" + }, + "edits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "range": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + }, + "end": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false + }, + "text": { + "type": "string", + "minLength": 0, + "maxLength": 262144, + "x-maxUtf8Bytes": 262144 + } + }, + "required": [ + "range", + "text" + ], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 500 + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "kind", + "path", + "baseRevision", + "edits" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "const": "create" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "content": { + "type": "string", + "minLength": 0, + "maxLength": 262144, + "x-maxUtf8Bytes": 262144 + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "kind", + "path", + "content" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "const": "move" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "baseRevision": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$" + }, + "newPath": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "kind", + "path", + "baseRevision", + "newPath" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "const": "delete" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "baseRevision": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$" + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "kind", + "path", + "baseRevision" + ], + "additionalProperties": false + } + ] + }, + "minItems": 1, + "maxItems": 32 + } + }, + "required": [ + "formatVersion", + "id", + "title", + "changes" + ], + "additionalProperties": false, + "x-maxUtf8Bytes": 1048576, + "$comment": "At most 1000 edits in all files. Runtime validates paths, scalar strings and edit geometry. Revisions are full content SHA-256. Destinations must be absent, and affected paths cannot be duplicated or nested." + }, + "requiredCapabilities": { + "enum": [ + [ + "write" + ], + [ + "write", + "manage" + ] + ] + }, + "ready": { + "type": "boolean" + }, + "conflicts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "code": { + "enum": [ + "destination_exists", + "source_missing", + "revision_changed", + "no_change" + ] + } + }, + "required": [ + "path", + "code" + ], + "additionalProperties": false + }, + "minItems": 0, + "maxItems": 64 + }, + "steps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "index": { + "type": "integer", + "minimum": 0, + "maximum": 31 + }, + "before": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "state": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "properties": { + "content": { + "type": "string", + "minLength": 0, + "maxLength": 262144, + "x-maxUtf8Bytes": 262144 + }, + "revision": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "content", + "revision" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "path", + "state" + ], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 2 + }, + "after": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "state": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "properties": { + "content": { + "type": "string", + "minLength": 0, + "maxLength": 262144, + "x-maxUtf8Bytes": 262144 + }, + "revision": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "content", + "revision" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "path", + "state" + ], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 2 + } + ] + }, + "diff": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "change": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "deleteCount": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "insertText": { + "type": "string", + "minLength": 0, + "maxLength": 262144, + "x-maxUtf8Bytes": 262144 + } + }, + "required": [ + "start", + "deleteCount", + "insertText" + ], + "additionalProperties": false + } + }, + "required": [ + "path", + "change" + ], + "additionalProperties": false + }, + "minItems": 0, + "maxItems": 2 + } + }, + "required": [ + "index", + "before", + "after", + "diff" + ], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 32 + }, + "digest": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "formatVersion", + "planId", + "workspace", + "edit", + "requiredCapabilities", + "ready", + "conflicts", + "steps", + "digest" + ], + "additionalProperties": false, + "x-maxUtf8Bytes": 8388608, + "$comment": "Runtime reproduces the entire preview from before snapshots and proposal, verifies snapshot hashes and the canonical digest, UTF-16 ranges, overlap, after content and single-hunk diffs. A ready preview is required for apply." +} diff --git a/schemas/workspace-edit-receipt.schema.json b/schemas/workspace-edit-receipt.schema.json new file mode 100644 index 0000000..a16136c --- /dev/null +++ b/schemas/workspace-edit-receipt.schema.json @@ -0,0 +1,159 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/workspace-edit-receipt.schema.json", + "title": "workspace-edit-receipt", + "type": "object", + "properties": { + "formatVersion": { + "const": 1 + }, + "planId": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" + }, + "digest": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$" + }, + "workspaceId": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" + }, + "status": { + "enum": [ + "completed", + "partial", + "not-applied", + "uncertain" + ] + }, + "steps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "index": { + "type": "integer", + "minimum": 0, + "maximum": 31 + }, + "state": { + "enum": [ + "applied", + "not-applied", + "conflicted", + "unknown" + ] + }, + "canCompensate": { + "type": "boolean" + }, + "errorCode": { + "enum": [ + "invalid_request", + "authentication_required", + "permission_denied", + "workspace_mismatch", + "generation_mismatch", + "not_found", + "conflict", + "unsafe_path", + "budget_exceeded", + "cancelled", + "disposed", + "plugin_mismatch", + "provider_failed", + "unsupported", + "unavailable", + "transport_failed" + ] + } + }, + "required": [ + "index", + "state", + "canCompensate" + ], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 32 + }, + "executionStopped": { + "type": "boolean" + }, + "errorCode": { + "enum": [ + "invalid_request", + "authentication_required", + "permission_denied", + "workspace_mismatch", + "generation_mismatch", + "not_found", + "conflict", + "unsafe_path", + "budget_exceeded", + "cancelled", + "disposed", + "plugin_mismatch", + "provider_failed", + "unsupported", + "unavailable", + "transport_failed" + ] + }, + "journalError": { + "enum": [ + "invalid_request", + "authentication_required", + "permission_denied", + "workspace_mismatch", + "generation_mismatch", + "not_found", + "conflict", + "unsafe_path", + "budget_exceeded", + "cancelled", + "disposed", + "plugin_mismatch", + "provider_failed", + "unsupported", + "unavailable", + "transport_failed" + ] + }, + "currentGeneration": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" + }, + "journalPhase": { + "enum": [ + "applying", + "completed", + "stopped" + ] + }, + "journalRevision": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "formatVersion", + "planId", + "digest", + "workspaceId", + "status", + "steps" + ], + "additionalProperties": false +} diff --git a/schemas/workspace-edit-record.schema.json b/schemas/workspace-edit-record.schema.json new file mode 100644 index 0000000..fb7ee68 --- /dev/null +++ b/schemas/workspace-edit-record.schema.json @@ -0,0 +1,598 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/workspace-edit-record.schema.json", + "title": "workspace-edit-record", + "type": "object", + "properties": { + "formatVersion": { + "const": 1 + }, + "preview": { + "type": "object", + "properties": { + "formatVersion": { + "const": 1 + }, + "planId": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" + }, + "workspace": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" + }, + "generation": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" + } + }, + "required": [ + "id", + "generation" + ], + "additionalProperties": false + }, + "edit": { + "type": "object", + "properties": { + "formatVersion": { + "const": 1 + }, + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "changes": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "const": "edit" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "baseRevision": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$" + }, + "edits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "range": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + }, + "end": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false + }, + "text": { + "type": "string", + "minLength": 0, + "maxLength": 262144, + "x-maxUtf8Bytes": 262144 + } + }, + "required": [ + "range", + "text" + ], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 500 + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "kind", + "path", + "baseRevision", + "edits" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "const": "create" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "content": { + "type": "string", + "minLength": 0, + "maxLength": 262144, + "x-maxUtf8Bytes": 262144 + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "kind", + "path", + "content" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "const": "move" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "baseRevision": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$" + }, + "newPath": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "kind", + "path", + "baseRevision", + "newPath" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "const": "delete" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "baseRevision": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$" + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "kind", + "path", + "baseRevision" + ], + "additionalProperties": false + } + ] + }, + "minItems": 1, + "maxItems": 32 + } + }, + "required": [ + "formatVersion", + "id", + "title", + "changes" + ], + "additionalProperties": false, + "x-maxUtf8Bytes": 1048576, + "$comment": "At most 1000 edits in all files. Runtime validates paths, scalar strings and edit geometry. Revisions are full content SHA-256. Destinations must be absent, and affected paths cannot be duplicated or nested." + }, + "requiredCapabilities": { + "enum": [ + [ + "write" + ], + [ + "write", + "manage" + ] + ] + }, + "ready": { + "type": "boolean" + }, + "conflicts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "code": { + "enum": [ + "destination_exists", + "source_missing", + "revision_changed", + "no_change" + ] + } + }, + "required": [ + "path", + "code" + ], + "additionalProperties": false + }, + "minItems": 0, + "maxItems": 64 + }, + "steps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "index": { + "type": "integer", + "minimum": 0, + "maximum": 31 + }, + "before": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "state": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "properties": { + "content": { + "type": "string", + "minLength": 0, + "maxLength": 262144, + "x-maxUtf8Bytes": 262144 + }, + "revision": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "content", + "revision" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "path", + "state" + ], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 2 + }, + "after": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "state": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "properties": { + "content": { + "type": "string", + "minLength": 0, + "maxLength": 262144, + "x-maxUtf8Bytes": 262144 + }, + "revision": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "content", + "revision" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "path", + "state" + ], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 2 + } + ] + }, + "diff": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "change": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "deleteCount": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "insertText": { + "type": "string", + "minLength": 0, + "maxLength": 262144, + "x-maxUtf8Bytes": 262144 + } + }, + "required": [ + "start", + "deleteCount", + "insertText" + ], + "additionalProperties": false + } + }, + "required": [ + "path", + "change" + ], + "additionalProperties": false + }, + "minItems": 0, + "maxItems": 2 + } + }, + "required": [ + "index", + "before", + "after", + "diff" + ], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 32 + }, + "digest": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "formatVersion", + "planId", + "workspace", + "edit", + "requiredCapabilities", + "ready", + "conflicts", + "steps", + "digest" + ], + "additionalProperties": false, + "x-maxUtf8Bytes": 8388608, + "$comment": "Runtime reproduces the entire preview from before snapshots and proposal, verifies snapshot hashes and the canonical digest, UTF-16 ranges, overlap, after content and single-hunk diffs. A ready preview is required for apply." + }, + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "phase": { + "enum": [ + "applying", + "completed", + "stopped" + ] + }, + "steps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "state": { + "enum": [ + "pending", + "intent", + "applied", + "not-applied", + "conflicted", + "unknown" + ] + }, + "errorCode": { + "enum": [ + "invalid_request", + "authentication_required", + "permission_denied", + "workspace_mismatch", + "generation_mismatch", + "not_found", + "conflict", + "unsafe_path", + "budget_exceeded", + "cancelled", + "disposed", + "plugin_mismatch", + "provider_failed", + "unsupported", + "unavailable", + "transport_failed" + ] + } + }, + "required": [ + "state" + ], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 32 + }, + "updatedAt": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "formatVersion", + "preview", + "revision", + "phase", + "steps", + "updatedAt" + ], + "additionalProperties": false, + "$comment": "Runtime checks ready preview, exact step count, applied prefix/pending suffix, at most one intent and completed state. Node storage adds a checksum envelope and filesystem/workspace identity checks." +} diff --git a/schemas/workspace-edit.schema.json b/schemas/workspace-edit.schema.json new file mode 100644 index 0000000..1c2e6ac --- /dev/null +++ b/schemas/workspace-edit.schema.json @@ -0,0 +1,243 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/workspace-edit.schema.json", + "title": "workspace-edit", + "type": "object", + "properties": { + "formatVersion": { + "const": 1 + }, + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "changes": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "const": "edit" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "baseRevision": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$" + }, + "edits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "range": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + }, + "end": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false + }, + "text": { + "type": "string", + "minLength": 0, + "maxLength": 262144, + "x-maxUtf8Bytes": 262144 + } + }, + "required": [ + "range", + "text" + ], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 500 + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "kind", + "path", + "baseRevision", + "edits" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "const": "create" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "content": { + "type": "string", + "minLength": 0, + "maxLength": 262144, + "x-maxUtf8Bytes": 262144 + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "kind", + "path", + "content" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "const": "move" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "baseRevision": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$" + }, + "newPath": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "kind", + "path", + "baseRevision", + "newPath" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "const": "delete" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "baseRevision": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$" + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "kind", + "path", + "baseRevision" + ], + "additionalProperties": false + } + ] + }, + "minItems": 1, + "maxItems": 32 + } + }, + "required": [ + "formatVersion", + "id", + "title", + "changes" + ], + "additionalProperties": false, + "x-maxUtf8Bytes": 1048576, + "$comment": "At most 1000 edits in all files. Runtime validates paths, scalar strings and edit geometry. Revisions are full content SHA-256. Destinations must be absent, and affected paths cannot be duplicated or nested." +} diff --git a/scripts/pack-check.mjs b/scripts/pack-check.mjs index 14f6e79..3c0c132 100644 --- a/scripts/pack-check.mjs +++ b/scripts/pack-check.mjs @@ -100,6 +100,8 @@ assert.equal(typeof createDiagnosticsResult, 'function'); run(process.execPath, ['language-example/run.mjs'], consumer); await cp(join(installed, 'examples', 'language-assistance'), join(consumer, 'assistance-example'), {recursive: true}); assert.match(run(process.execPath, ['assistance-example/run.mjs'], consumer), /nested signature help, lazy completion resolution and Unicode snippet preview verified/); + await cp(join(installed, 'examples', 'workspace-edits'), join(consumer, 'edits-example'), {recursive: true}); + assert.match(run(process.execPath, ['edits-example/run.mjs'], consumer), /rename preview, external-edit conflict, reviewed CAS and killed-process recovery verified/); await cp(join(installed, 'examples', 'project-session'), join(consumer, 'project-example'), {recursive: true}); run(process.execPath, ['project-example/run.mjs'], consumer); // Package and install a real independent plugin. npm sees generated metadata; @@ -195,6 +197,7 @@ try { await cp(join(root, 'tests', 'types', 'core-v2.mts'), join(consumer, 'core-v2.mts')); await cp(join(root, 'tests', 'types', 'language.mts'), join(consumer, 'language.mts')); await cp(join(root, 'tests', 'types', 'language-assistance.mts'), join(consumer, 'language-assistance.mts')); + await cp(join(root, 'tests', 'types', 'workspace-edits.mts'), join(consumer, 'workspace-edits.mts')); await cp(join(root, 'tests', 'types', 'workspace-project.mts'), join(consumer, 'workspace-project.mts')); await cp(join(root, 'tests', 'types', 'jobs-devtools.mts'), join(consumer, 'jobs-devtools.mts')); await cp(join(root, 'tests', 'types', 'workspace-observation.mts'), join(consumer, 'workspace-observation.mts')); diff --git a/src/contracts.mjs b/src/contracts.mjs index ed9b33f..f72b09f 100644 --- a/src/contracts.mjs +++ b/src/contracts.mjs @@ -1,7 +1,8 @@ import { ErrorCode, LIMITS, PluginSdkError, PROTOCOL_VERSION } from './limits.mjs'; import { SEMVER_PATTERN } from './patterns.mjs'; import {parseCompletionItem, parseLanguageContext, parseSignatureHelp, completionEdits} from './language-assistance.mjs'; -import {textIndex} from './language-values.mjs'; +import {textIndex, languageText, languageCall} from './language-values.mjs'; +import {parseWorkspaceEdit} from './workspace-edit-contracts.mjs'; const encoder = new TextEncoder(); const identifierPattern = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/; @@ -10,7 +11,7 @@ const uriPattern = /^[A-Za-z][A-Za-z0-9+.-]*:[^\s\u0000-\u001f\u007f]+$/; const permissions = ['document.read', 'diagnostics.publish']; const workspacePermissions = ['workspace.read', 'workspace.write', 'backend.invoke']; const allPermissions = [...permissions, ...workspacePermissions, 'language.provide']; -export const LANGUAGE_FEATURES = Object.freeze(['completion', 'hover', 'definition', 'references', 'document-symbols', 'signature-help']); +export const LANGUAGE_FEATURES = Object.freeze(['completion', 'hover', 'definition', 'references', 'document-symbols', 'signature-help', 'prepare-rename', 'rename']); export const LANGUAGE_CAPABILITIES = Object.freeze([...LANGUAGE_FEATURES, 'completion-resolve', 'completion-snippets']); function fail(path, message) { @@ -430,7 +431,7 @@ function inDocumentRange(value, text) { /** Language API v1: returned text is never HTML or a command. Snippets are explicit data. */ export function parseLanguageRequest(value) { value = input(value, LIMITS.requestBytes, 'request'); - record(value, ['protocolVersion', 'requestId', 'scope', 'snapshot', 'kind'], ['position', 'includeDeclaration', 'context'], 'request'); + record(value, ['protocolVersion', 'requestId', 'scope', 'snapshot', 'kind'], ['position', 'includeDeclaration', 'context', 'newName'], 'request'); const kind = enumeration(value.kind, LANGUAGE_FEATURES, 'request.kind'); const output = { protocolVersion: version(value.protocolVersion, 'request.protocolVersion'), @@ -449,6 +450,10 @@ export function parseLanguageRequest(value) { output.includeDeclaration = value.includeDeclaration; } if (Object.hasOwn(value, 'context')) output.context = parseLanguageContext(kind, value.context); + if (kind === 'rename') { + output.newName = languageText(value.newName, 256); + if (/[\u0000-\u001f\u007f]/.test(output.newName)) fail('request.newName', 'control characters are unsupported'); + } else if (Object.hasOwn(value, 'newName')) fail('request.newName', 'only rename accepts a new name'); return boundCopy(Object.freeze(output), LIMITS.requestBytes, 'request'); } @@ -478,6 +483,14 @@ function symbol(value, path) { return Object.freeze(output); } +function renamePreparation(value) { + if (value === null) return null; + record(value, ['range', 'placeholder'], [], 'result.data'); + const selection = range(value.range, 'result.data.range'); + if (comparePosition(selection.start, selection.end) >= 0) fail('result.data.range', 'rename range must be nonempty'); + return Object.freeze({range: selection, placeholder: languageText(value.placeholder, 256)}); +} + export function parseLanguageResult(value) { value = input(value, LIMITS.resultBytes, 'result'); record(value, ['protocolVersion', 'requestId', 'scope', 'snapshot', 'kind', 'data'], [], 'result'); @@ -487,14 +500,24 @@ export function parseLanguageResult(value) { protocolVersion: version(value.protocolVersion, 'result.protocolVersion'), requestId: string(value.requestId, 128, 'result.requestId'), scope: scope(value.scope, 'result.scope'), snapshot: snapshot(value.snapshot, 'result.snapshot', false), kind, - data: kind === 'signature-help' ? parseSignatureHelp(value.data) : kind === 'hover' ? hover(value.data, 'result.data') : Object.freeze(array(value.data, LIMITS.maxLanguageItems, 'result.data', readers[kind])), + data: kind === 'signature-help' ? parseSignatureHelp(value.data) + : kind === 'prepare-rename' ? renamePreparation(value.data) + : kind === 'rename' ? value.data === null ? null : languageCall(() => parseWorkspaceEdit(value.data)) + : kind === 'hover' ? hover(value.data, 'result.data') : Object.freeze(array(value.data, LIMITS.maxLanguageItems, 'result.data', readers[kind])), }), LIMITS.resultBytes, 'result'); } export function assertLanguageResultMatchesRequest(result, request) { assertIdentityMatches(result, request); if (result.kind !== request.kind) fail('result.kind', 'does not match request'); - if (result.kind === 'signature-help') return; + if (['signature-help', 'rename'].includes(result.kind)) return; + if (result.kind === 'prepare-rename') { + if (result.data !== null) { + textIndex(request.snapshot.text).range(result.data.range); + if (comparePosition(result.data.range.start, request.position) > 0 || comparePosition(request.position, result.data.range.end) > 0) fail('result.data.range', 'rename range must contain the request position'); + } + return; + } if (result.kind === 'completion') { const index = textIndex(request.snapshot.text); for (const item of result.data) completionEdits(request, item, index); diff --git a/src/host.mjs b/src/host.mjs index 192e604..b0cb9a2 100644 --- a/src/host.mjs +++ b/src/host.mjs @@ -345,7 +345,7 @@ export function createPluginHost({hostId = 'test-host', scope = {projectId: 'exa if (!document) throw error(ErrorCode.INVALID_CONTRACT, 'Set a document before requesting language features'); if (!languages.has(kind)) throw error(ErrorCode.INVALID_CONTRACT, 'Unknown language feature'); const value = parseJsonValue(input); - if (!value || Array.isArray(value) || typeof value !== 'object' || Object.keys(value).some(key => !['position', 'includeDeclaration', 'context'].includes(key))) throw error(ErrorCode.INVALID_CONTRACT, 'Expected language request input'); + if (!value || Array.isArray(value) || typeof value !== 'object' || Object.keys(value).some(key => !['position', 'includeDeclaration', 'context', 'newName'].includes(key))) throw error(ErrorCode.INVALID_CONTRACT, 'Expected language request input'); const request = parseLanguageRequest({protocolVersion: 1, requestId: `request-${++nextRequest}`, scope, snapshot: document, kind, ...value}); const currentRevision = revision; const result = await languages.get(kind).request(request, options); diff --git a/src/index.d.mts b/src/index.d.mts index 4cee8b7..1b5526c 100644 --- a/src/index.d.mts +++ b/src/index.d.mts @@ -192,16 +192,16 @@ export interface PluginHost extends Disposable { /** Executes trusted plugins in-process. Ports enforce file/backend authority; this is not a sandbox. */ export function createPluginHost(options?: PluginHostOptions): PluginHost; -export type LanguageFeature = 'completion' | 'hover' | 'definition' | 'references' | 'document-symbols' | 'signature-help'; +export type LanguageFeature = 'completion' | 'hover' | 'definition' | 'references' | 'document-symbols' | 'signature-help' | 'prepare-rename' | 'rename'; export type LanguageCapability = LanguageFeature | 'completion-resolve' | 'completion-snippets'; export const LANGUAGE_FEATURES: readonly LanguageFeature[]; export const LANGUAGE_CAPABILITIES: readonly LanguageCapability[]; export type LanguageInput = K extends 'document-symbols' - ? {readonly position?: never; readonly includeDeclaration?: never; readonly context?: never} + ? {readonly position?: never; readonly includeDeclaration?: never; readonly context?: never; readonly newName?: never} : {readonly position: Position} & (K extends 'references' ? {readonly includeDeclaration?: boolean; readonly context?: never} : K extends 'completion' ? {readonly includeDeclaration?: never; readonly context?: CompletionContext} : K extends 'signature-help' ? {readonly includeDeclaration?: never; readonly context?: SignatureHelpContext} - : {readonly includeDeclaration?: never; readonly context?: never}); + : {readonly includeDeclaration?: never; readonly context?: never}) & (K extends 'rename' ? {readonly newName: string} : {readonly newName?: never}); export type LanguageRequest = K extends LanguageFeature ? DiagnosticsRequest & {readonly kind: K} & LanguageInput : never; /** Literal by default. A declared snippet capability enables the bounded data-only subset. */ @@ -240,6 +240,7 @@ export function parseSnippet(value: unknown): ParsedSnippet; export function parseCompletionItem(value: unknown): CompletionItem; export function parseSignatureHelp(value: unknown): SignatureHelp | null; export interface Hover {readonly text: string; readonly range?: Range;} +export interface RenamePreparation {readonly range: Range; readonly placeholder: string;} /** Relative workspace path. Hosts must authorize access before opening the target. */ export interface LanguageLocation {readonly path: string; readonly range: Range;} export interface DocumentSymbol { @@ -253,6 +254,8 @@ export interface LanguageData { readonly references: readonly LanguageLocation[]; readonly 'document-symbols': readonly DocumentSymbol[]; readonly 'signature-help': SignatureHelp | null; + readonly 'prepare-rename': RenamePreparation | null; + readonly rename: import('./workspace-edits.mjs').WorkspaceEdit | null; } export type LanguageResult = K extends LanguageFeature ? Omit & {readonly kind: K; readonly data: LanguageData[K]} : never; diff --git a/src/schemas.mjs b/src/schemas.mjs index c13603f..75996e1 100644 --- a/src/schemas.mjs +++ b/src/schemas.mjs @@ -16,6 +16,7 @@ import {WORKSPACE_LIMITS} from './workspace-protocol.mjs'; import { LANGUAGE_FEATURES, LANGUAGE_CAPABILITIES } from './contracts.mjs'; import {LANGUAGE_ASSISTANCE_SCHEMAS, COMPLETION_ITEM_SCHEMA, SIGNATURE_HELP_SCHEMA, languageContextSchema} from './language-assistance-schemas.mjs'; import {SDK_VERSION} from './version.mjs'; +import {WORKSPACE_EDIT_SCHEMA, WORKSPACE_EDIT_SCHEMAS} from './workspace-edit-schemas.mjs'; const schema = 'https://json-schema.org/draft/2020-12/schema'; const base = `https://github.com/Altifigence/dds-plugin-sdk/blob/v${SDK_VERSION}/schemas/`; @@ -77,6 +78,8 @@ const location = object({path: {...text(1024), $comment: 'Runtime additionally e const languageData = { completion: list(COMPLETION_ITEM_SCHEMA, LIMITS.maxLanguageItems), 'signature-help': SIGNATURE_HELP_SCHEMA, + 'prepare-rename': {oneOf: [{type: 'null'}, object({range, placeholder: text(256)})]}, + rename: {oneOf: [{type: 'null'}, WORKSPACE_EDIT_SCHEMA]}, hover: {oneOf: [{type: 'null'}, object({text: text(16_384), range}, ['text'])]}, definition: list(location, LIMITS.maxLanguageItems), references: list(location, LIMITS.maxLanguageItems), 'document-symbols': list(object({name: text(256), kind: {enum: ['module', 'namespace', 'class', 'interface', 'function', 'method', 'variable', 'constant', 'property', 'type']}, range, selectionRange: range, detail: text(2048)}, ['name', 'kind', 'range', 'selectionRange']), LIMITS.maxLanguageItems), @@ -105,6 +108,7 @@ const historyDisposition = {enum:['live','completed','interrupted','expired']}; const historyCursor = {...text(40),pattern:'^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}:(?:0|[1-9][0-9]{0,2})$'}; const historyItem = object({jobId,commandId:text(128),state:{enum:JOB_STATES},disposition:historyDisposition,startedAt:integer(Number.MAX_SAFE_INTEGER),updatedAt:integer(Number.MAX_SAFE_INTEGER),expiresAt:integer(Number.MAX_SAFE_INTEGER,1),revision:integer(Number.MAX_SAFE_INTEGER,1),attemptOf:{oneOf:[jobId,{type:'null'}]},contentPolicy:{enum:['metadata-only','host-redacted']},artifactCount:integer(JOB_LIMITS.artifacts),snapshotCount:integer(JOB_LIMITS.artifacts),resultAvailability:{enum:['none','source-references','snapshot-references','mixed-references','expired']}}); export const SCHEMAS = Object.freeze({ + ...Object.fromEntries(Object.entries(WORKSPACE_EDIT_SCHEMAS).map(([name, body]) => [name, define(name, body)])), ...Object.fromEntries(Object.entries(LANGUAGE_ASSISTANCE_SCHEMAS).map(([name, body]) => [name, define(name, body)])), ...Object.fromEntries(Object.entries({...PROJECT_WATCH_SCHEMAS,...PROJECT_QUERY_SCHEMAS,...WORKSPACE_PROJECT_SCHEMAS,...ARTIFACT_TRANSFER_SCHEMAS,...UPLOAD_SCHEMAS,...TRANSFER_QUEUE_SCHEMAS}).map(([name,body])=>[name,define(name,body)])), 'stored-artifact':define('stored-artifact',{...storedArtifact,$comment:'Runtime checks identity, labels are excluded, and capturedAt/expiresAt ordering and maximum retention.'}), @@ -159,8 +163,8 @@ export const SCHEMAS = Object.freeze({ 'language-request': define('language-request', {oneOf: LANGUAGE_FEATURES.map(kind => object({ protocolVersion: {const: 1}, requestId: text(128), scope, snapshot: object({...identity, text: {type: 'string', maxLength: LIMITS.documentBytes, 'x-maxUtf8Bytes': LIMITS.documentBytes}}), - kind: {const: kind}, ...(kind === 'document-symbols' ? {} : {position}), ...(kind === 'references' ? {includeDeclaration: {type: 'boolean'}} : {}), ...(['completion', 'signature-help'].includes(kind) ? {context: languageContextSchema(kind)} : {}), - }, ['protocolVersion', 'requestId', 'scope', 'snapshot', 'kind', ...(kind === 'document-symbols' ? [] : ['position'])]))}), + kind: {const: kind}, ...(kind === 'document-symbols' ? {} : {position}), ...(kind === 'references' ? {includeDeclaration: {type: 'boolean'}} : {}), ...(['completion', 'signature-help'].includes(kind) ? {context: languageContextSchema(kind)} : {}), ...(kind === 'rename' ? {newName: text(256)} : {}), + }, ['protocolVersion', 'requestId', 'scope', 'snapshot', 'kind', ...(kind === 'document-symbols' ? [] : ['position']), ...(kind === 'rename' ? ['newName'] : [])]))}), 'language-result': define('language-result', {oneOf: LANGUAGE_FEATURES.map(kind => object({ protocolVersion: {const: 1}, requestId: text(128), scope, snapshot: object(identity), kind: {const: kind}, data: languageData[kind], }))}), diff --git a/src/workspace-edit-contracts.mjs b/src/workspace-edit-contracts.mjs new file mode 100644 index 0000000..3b74ea6 --- /dev/null +++ b/src/workspace-edit-contracts.mjs @@ -0,0 +1,158 @@ +import {copyWorkspaceJson, exactObject, requireUuid, requireText, requireSha256, requireWorkspacePath, requireFileContent, workspaceFailure, WORKSPACE_ERROR_CODES} from './workspace-values.mjs'; +import {languageRange} from './language-values.mjs'; +import {applyTextEdits} from './text-edits.mjs'; +import {createIncrementalSha256} from './sha256-stream.mjs'; + +export const WORKSPACE_EDIT_LIMITS = Object.freeze({changes: 32, edits: 1000, proposalBytes: 1_048_576, previewBytes: 8_388_608, journalRecords: 64, journalBytes: 67_108_864}); +export const WORKSPACE_EDIT_CAPABILITIES = Object.freeze({formatVersion: 1, kinds: Object.freeze(['edit', 'create', 'move', 'delete']), atomicity: 'per-file', recovery: 'readback-only', limits: WORKSPACE_EDIT_LIMITS}); +export const editFailure = code => workspaceFailure(code, 'Workspace edit operation could not be completed'); +export const editCopy = (value, maxBytes = WORKSPACE_EDIT_LIMITS.previewBytes) => copyWorkspaceJson(value, {maxBytes, maxNodes: 100_000, maxDepth: 24}); +export const editHash = text => createIncrementalSha256().update(new TextEncoder().encode(text)).digest(); +export function canonicalEditJson(value) { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return '[' + value.map(canonicalEditJson).join(',') + ']'; + return '{' + Object.keys(value).sort().map(key => JSON.stringify(key) + ':' + canonicalEditJson(value[key])).join(',') + '}'; +} +const invalid = () => {throw editFailure('invalid_request');}; +function list(value, maximum, minimum = 0) {if (!Array.isArray(value) || value.length < minimum || value.length > maximum) invalid(); return value;} +export function parseWorkspaceEdit(input) { + const value = editCopy(input, WORKSPACE_EDIT_LIMITS.proposalBytes); + exactObject(value, ['formatVersion', 'id', 'title', 'changes']); + if (value.formatVersion !== 1) invalid(); requireUuid(value.id); requireText(value.title, 256); + let edits = 0; const paths = []; + for (const change of list(value.changes, WORKSPACE_EDIT_LIMITS.changes, 1)) { + const fields = {edit: ['baseRevision', 'edits'], create: ['content'], move: ['baseRevision', 'newPath'], delete: ['baseRevision']}[change?.kind]; + if (!fields) invalid(); + exactObject(change, ['kind', 'path', ...fields], ['reason']); + requireWorkspacePath(change.path); paths.push(change.path.toLowerCase()); + if (change.reason !== undefined) requireText(change.reason, 2048); + if (change.kind !== 'create') requireSha256(change.baseRevision); + if (change.kind === 'create') requireFileContent(change.content); + if (change.kind === 'move') {requireWorkspacePath(change.newPath); paths.push(change.newPath.toLowerCase());} + if (change.kind === 'edit') for (const edit of list(change.edits, 500, 1)) { + if (++edits > WORKSPACE_EDIT_LIMITS.edits) throw editFailure('budget_exceeded'); + exactObject(edit, ['range', 'text']); + try {languageRange(edit.range);} catch {invalid();} + requireFileContent(edit.text); + } + } + const distinct = new Set(paths); + // A proposal has disjoint paths on every platform; directory operations and + // dependent chains must be separately reviewed plans with fresh revisions. + if (distinct.size !== paths.length) invalid(); + for (const file of paths) { + const parts = file.split('/'); parts.pop(); + while (parts.length) {if (distinct.has(parts.join('/'))) invalid(); parts.pop();} + } + return value; +} +export const changePaths = change => change.kind === 'move' ? [change.path, change.newPath] : [change.path]; +export const contentState = content => content === null ? null : {content, revision: editHash(requireFileContent(content))}; +export function parseEditFileState(value) { + if (value === null) return null; + exactObject(value, ['content', 'revision']); requireFileContent(value.content); requireSha256(value.revision); + if (editHash(value.content) !== value.revision) throw editFailure('conflict'); + return value; +} +export function textDifference(before, after) { + before ??= ''; after ??= ''; let start = 0, end = 0; + const boundary = (text, offset) => !(offset > 0 && offset < text.length && (/^[\uD800-\uDBFF]$/.test(text[offset - 1]) && /^[\uDC00-\uDFFF]$/.test(text[offset]) || text[offset - 1] === '\r' && text[offset] === '\n')); + while (start < before.length && start < after.length && before[start] === after[start]) start++; + while (start && (!boundary(before, start) || !boundary(after, start))) start--; + while (end < before.length - start && end < after.length - start && before[before.length - end - 1] === after[after.length - end - 1]) end++; + while (end && (!boundary(before, before.length - end) || !boundary(after, after.length - end))) end--; + return {start, deleteCount: before.length - start - end, insertText: after.slice(start, after.length - end)}; +} + +/** Derive a reproducible preview entirely from validated original file snapshots. */ +export function buildEditPreview(edit, workspace, originals) { + edit = parseWorkspaceEdit(edit); exactObject(workspace, ['id', 'generation']); requireUuid(workspace.id); requireUuid(workspace.generation); + list(originals, WORKSPACE_EDIT_LIMITS.changes * 2); + const expected = edit.changes.flatMap(changePaths); + if (originals.length !== expected.length) invalid(); + const byPath = new Map(); + for (let n = 0; n < originals.length; n++) { + const file = originals[n]; exactObject(file, ['path', 'state']); + if (file.path !== expected[n]) invalid(); + byPath.set(file.path, parseEditFileState(file.state)); + } + const conflicts = [], steps = []; + const conflict = (path, code) => conflicts.push({path, code}); + for (let index = 0; index < edit.changes.length; index++) { + const change = edit.changes[index], before = changePaths(change).map(path => ({path, state: byPath.get(path)})); + const source = before[0].state, previousConflicts = conflicts.length; let after = null; + if (change.kind === 'create') { + if (source !== null) conflict(change.path, 'destination_exists'); + else after = [{path: change.path, state: contentState(change.content)}]; + } else { + if (source === null) conflict(change.path, 'source_missing'); + else if (source.revision !== change.baseRevision) conflict(change.path, 'revision_changed'); + if (change.kind === 'move' && before[1].state !== null) conflict(change.newPath, 'destination_exists'); + if (conflicts.length === previousConflicts) { + if (change.kind === 'edit') { + const state = contentState(applyTextEdits(source.content, change.edits)); + if (state.revision === source.revision) conflict(change.path, 'no_change'); + else after = [{path: change.path, state}]; + } else if (change.kind === 'delete') after = [{path: change.path, state: null}]; + else after = [{path: change.path, state: null}, {path: change.newPath, state: source}]; + } + } + steps.push({index, before, after, diff: after === null ? [] : after.map((file, n) => ({path: file.path, change: textDifference(before[n].state?.content ?? null, file.state?.content ?? null)}))}); + } + const requiredCapabilities = ['write', ...(edit.changes.some(change => ['move', 'delete'].includes(change.kind)) ? ['manage'] : [])]; + const payload = editCopy({formatVersion: 1, planId: edit.id, workspace, edit, requiredCapabilities, ready: conflicts.length === 0, conflicts, steps}); + return editCopy({...payload, digest: editHash(canonicalEditJson(payload))}); +} +export function parseWorkspaceEditPreview(input) { + const value = editCopy(input); + exactObject(value, ['formatVersion', 'planId', 'workspace', 'edit', 'requiredCapabilities', 'ready', 'conflicts', 'steps', 'digest']); + requireSha256(value.digest); + list(value.steps, WORKSPACE_EDIT_LIMITS.changes, 1); + const originals = value.steps.flatMap(step => { + exactObject(step, ['index', 'before', 'after', 'diff']); return list(step.before, 2, 1); + }); + const derived = buildEditPreview(value.edit, value.workspace, originals); + if (canonicalEditJson(value) !== canonicalEditJson(derived)) throw editFailure('conflict'); + return derived; +} +export function parseWorkspaceEditRecord(input) { + const value = editCopy(input, WORKSPACE_EDIT_LIMITS.previewBytes + 65_536); + exactObject(value, ['formatVersion', 'preview', 'revision', 'phase', 'steps', 'updatedAt']); + const preview = parseWorkspaceEditPreview(value.preview); + if (value.formatVersion !== 1 || !preview.ready || !Number.isSafeInteger(value.revision) || value.revision < 1 || !Number.isSafeInteger(value.updatedAt) || value.updatedAt < 0 || !['applying', 'completed', 'stopped'].includes(value.phase)) invalid(); + if (list(value.steps, WORKSPACE_EDIT_LIMITS.changes, 1).length !== preview.steps.length) invalid(); + let encounteredPending = false, intents = 0; + for (const step of value.steps) { + exactObject(step, ['state'], ['errorCode']); + if (!['pending', 'intent', 'applied', 'not-applied', 'conflicted', 'unknown'].includes(step.state)) invalid(); + if (step.errorCode !== undefined && !WORKSPACE_ERROR_CODES.includes(step.errorCode)) invalid(); + if (step.state === 'pending') encounteredPending = true; + else if (encounteredPending) invalid(); + if (step.state === 'intent') intents++; + } + if (intents > 1 || value.phase === 'completed' && value.steps.some(step => step.state !== 'applied')) invalid(); + return value; +} + +export function parseWorkspaceEditReceipt(input) { + const value = editCopy(input, 65_536); + exactObject(value, ['formatVersion', 'planId', 'digest', 'workspaceId', 'status', 'steps'], ['executionStopped', 'errorCode', 'journalError', 'currentGeneration', 'journalPhase', 'journalRevision']); + requireUuid(value.planId); requireUuid(value.workspaceId); requireSha256(value.digest); + if (value.formatVersion !== 1) invalid(); + for (const [index, step] of list(value.steps, WORKSPACE_EDIT_LIMITS.changes, 1).entries()) { + exactObject(step, ['index', 'state', 'canCompensate'], ['errorCode']); + if (step.index !== index || !['applied', 'not-applied', 'conflicted', 'unknown'].includes(step.state) || step.canCompensate !== (step.state === 'applied') || step.errorCode !== undefined && !WORKSPACE_ERROR_CODES.includes(step.errorCode)) invalid(); + } + const applied = value.steps.filter(step => step.state === 'applied').length; + const expected = value.steps.some(step => ['unknown', 'conflicted'].includes(step.state)) ? 'uncertain' : applied === value.steps.length ? 'completed' : applied ? 'partial' : 'not-applied'; + if (value.status !== expected) invalid(); + for (const key of ['errorCode', 'journalError']) if (value[key] !== undefined && !WORKSPACE_ERROR_CODES.includes(value[key])) invalid(); + if (value.executionStopped !== undefined) { + if (typeof value.executionStopped !== 'boolean' || ['currentGeneration', 'journalPhase', 'journalRevision'].some(key => value[key] !== undefined)) invalid(); + if (!value.executionStopped && (value.status !== 'completed' || value.errorCode !== undefined || value.journalError !== undefined)) invalid(); + } else { + requireUuid(value.currentGeneration); + if (!['applying', 'completed', 'stopped'].includes(value.journalPhase) || !Number.isSafeInteger(value.journalRevision) || value.journalRevision < 1 || value.errorCode !== undefined || value.journalError !== undefined) invalid(); + } + return value; +} diff --git a/src/workspace-edit-schemas.mjs b/src/workspace-edit-schemas.mjs new file mode 100644 index 0000000..3470ef6 --- /dev/null +++ b/src/workspace-edit-schemas.mjs @@ -0,0 +1,31 @@ +import {WORKSPACE_EDIT_LIMITS as limits} from './workspace-edit-contracts.mjs'; +import {WORKSPACE_ERROR_CODES} from './workspace-values.mjs'; +const object = (properties, required = Object.keys(properties)) => ({type: 'object', properties, required, additionalProperties: false}); +const text = (maximum, minimum = 0) => ({type: 'string', minLength: minimum, maxLength: maximum}); +const integer = maximum => ({type: 'integer', minimum: 0, maximum}); +const list = (items, maxItems, minItems = 0) => ({type: 'array', items, minItems, maxItems}); +const uuid = {...text(36, 36), pattern: '^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$'}; +const hash = {...text(64, 64), pattern: '^[a-f0-9]{64}$'}; +const path = {...text(1024, 1), $comment: 'Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths.'}; +const content = {...text(262_144), 'x-maxUtf8Bytes': 262_144}; +const position = object({line: integer(262_144), character: integer(262_144)}); +const range = object({start: position, end: position}); +const edit = object({range, text: content}); +const change = {oneOf: ['edit', 'create', 'move', 'delete'].map(kind => { + const fields = kind === 'edit' ? {baseRevision: hash, edits: list(edit, 500, 1)} : kind === 'create' ? {content} : kind === 'move' ? {baseRevision: hash, newPath: path} : {baseRevision: hash}; + return object({kind: {const: kind}, path, ...fields, reason: text(2048, 1)}, ['kind', 'path', ...Object.keys(fields)]); +})}; +export const WORKSPACE_EDIT_SCHEMA = {...object({formatVersion: {const: 1}, id: uuid, title: text(256, 1), changes: list(change, limits.changes, 1)}), 'x-maxUtf8Bytes': limits.proposalBytes, $comment: 'At most 1000 edits in all files. Runtime validates paths, scalar strings and edit geometry. Revisions are full content SHA-256. Destinations must be absent, and affected paths cannot be duplicated or nested.'}; +const fileState = {oneOf: [{type: 'null'}, object({content, revision: hash})]}; +const pathState = object({path, state: fileState}); +const workspace = object({id: uuid, generation: uuid}); +const previewStep = object({index: integer(limits.changes - 1), before: list(pathState, 2, 1), after: {oneOf: [{type: 'null'}, list(pathState, 2, 1)]}, diff: list(object({path, change: object({start: integer(262_144), deleteCount: integer(262_144), insertText: content})}), 2)}); +const preview = {...object({formatVersion: {const: 1}, planId: uuid, workspace, edit: WORKSPACE_EDIT_SCHEMA, requiredCapabilities: {enum: [['write'], ['write', 'manage']]}, ready: {type: 'boolean'}, conflicts: list(object({path, code: {enum: ['destination_exists', 'source_missing', 'revision_changed', 'no_change']}}), limits.changes * 2), steps: list(previewStep, limits.changes, 1), digest: hash}), 'x-maxUtf8Bytes': limits.previewBytes, $comment: 'Runtime reproduces the entire preview from before snapshots and proposal, verifies snapshot hashes and the canonical digest, UTF-16 ranges, overlap, after content and single-hunk diffs. A ready preview is required for apply.'}; +const observed = ['applied', 'not-applied', 'conflicted', 'unknown']; +const recordStep = object({state: {enum: ['pending', 'intent', ...observed]}, errorCode: {enum: WORKSPACE_ERROR_CODES}}, ['state']); +export const WORKSPACE_EDIT_SCHEMAS = Object.freeze({ + 'workspace-edit': WORKSPACE_EDIT_SCHEMA, + 'workspace-edit-preview': preview, + 'workspace-edit-record': {...object({formatVersion: {const: 1}, preview, revision: {...integer(Number.MAX_SAFE_INTEGER), minimum: 1}, phase: {enum: ['applying', 'completed', 'stopped']}, steps: list(recordStep, limits.changes, 1), updatedAt: integer(Number.MAX_SAFE_INTEGER)}), $comment: 'Runtime checks ready preview, exact step count, applied prefix/pending suffix, at most one intent and completed state. Node storage adds a checksum envelope and filesystem/workspace identity checks.'}, + 'workspace-edit-receipt': object({formatVersion: {const: 1}, planId: uuid, digest: hash, workspaceId: uuid, status: {enum: ['completed', 'partial', 'not-applied', 'uncertain']}, steps: list(object({index: integer(limits.changes - 1), state: {enum: observed}, canCompensate: {type: 'boolean'}, errorCode: {enum: WORKSPACE_ERROR_CODES}}, ['index', 'state', 'canCompensate']), limits.changes, 1), executionStopped: {type: 'boolean'}, errorCode: {enum: WORKSPACE_ERROR_CODES}, journalError: {enum: WORKSPACE_ERROR_CODES}, currentGeneration: uuid, journalPhase: {enum: ['applying', 'completed', 'stopped']}, journalRevision: {...integer(Number.MAX_SAFE_INTEGER), minimum: 1}}, ['formatVersion', 'planId', 'digest', 'workspaceId', 'status', 'steps']), +}); diff --git a/src/workspace-edits-node.d.mts b/src/workspace-edits-node.d.mts new file mode 100644 index 0000000..c136bba --- /dev/null +++ b/src/workspace-edits-node.d.mts @@ -0,0 +1,8 @@ +import type {WorkspaceEditJournal, WorkspaceEditRecord, WORKSPACE_EDIT_LIMITS} from './workspace-edits.mjs'; +import type {WorkspaceErrorCode} from './workspace-protocol.mjs'; +export interface NodeWorkspaceEditJournal extends WorkspaceEditJournal { + readonly capabilities: Readonly<{formatVersion: 1; workspaceId: string; durability: 'file-fsync-rename'; directoryFsync: boolean; singleWriter: true; limits: typeof WORKSPACE_EDIT_LIMITS}>; + list(): Promise; + close(): Promise; +} +export function createNodeWorkspaceEditJournal(options: {readonly directory: string; readonly workspaceRoot: string; readonly workspaceId: string; readonly recoverStaleLock?: boolean}): Promise; diff --git a/src/workspace-edits-node.mjs b/src/workspace-edits-node.mjs new file mode 100644 index 0000000..c1fd1cd --- /dev/null +++ b/src/workspace-edits-node.mjs @@ -0,0 +1,207 @@ +import * as fs from 'node:fs/promises'; +import {constants as flags} from 'node:fs'; +import path from 'node:path'; +import {hostname} from 'node:os'; +import {randomUUID} from 'node:crypto'; +import {requireUuid, exactObject} from './workspace-values.mjs'; +import {nodeWorkspaceIdentity} from './workspace-identity-node.mjs'; +import {WORKSPACE_EDIT_LIMITS, parseWorkspaceEditRecord, canonicalEditJson, editHash, editCopy, editFailure} from './workspace-edit-contracts.mjs'; + +const maximum = WORKSPACE_EDIT_LIMITS.previewBytes + 131_072; +const same = (a, b) => a.dev === b.dev && a.ino === b.ino; +const contained = (root, target) => {const relative = path.relative(root, target); return relative === '' || relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative);}; +const isId = value => /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(value); +const mapped = error => { + if (error?.name === 'WorkspaceError') return error; + return editFailure(['EEXIST', 'ENOTEMPTY'].includes(error?.code) ? 'conflict' : ['EPERM', 'EACCES'].includes(error?.code) ? 'permission_denied' : ['ENOSPC', 'EDQUOT', 'EFBIG'].includes(error?.code) ? 'budget_exceeded' : error?.code === 'ENOENT' ? 'not_found' : 'provider_failed'); +}; +async function realDirectory(value) { + if (typeof value !== 'string' || !path.isAbsolute(value)) throw editFailure('invalid_request'); + const supplied = await fs.lstat(value, {bigint: true}); + if (!supplied.isDirectory() || supplied.isSymbolicLink()) throw editFailure('unsafe_path'); + const canonical = await fs.realpath(value), stat = await fs.lstat(canonical, {bigint: true}); + if (!stat.isDirectory() || stat.isSymbolicLink() || !same(supplied, stat)) throw editFailure('conflict'); + return {canonical, stat}; +} +async function regularBytes(file, limit = maximum) { + const before = await fs.lstat(file, {bigint: true}); + if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1n || before.size > BigInt(limit)) throw editFailure('unsafe_path'); + const handle = await fs.open(file, flags.O_RDONLY | (flags.O_NOFOLLOW ?? 0)); + try { + const opened = await handle.stat({bigint: true}); + if (!same(opened, before) || !opened.isFile() || opened.nlink !== 1n || opened.size !== before.size) throw editFailure('conflict'); + const bytes = Buffer.alloc(Number(opened.size)); let offset = 0; + while (offset < bytes.length) {const result = await handle.read(bytes, offset, bytes.length - offset, offset); if (!result.bytesRead) throw editFailure('conflict'); offset += result.bytesRead;} + const after = await handle.stat({bigint: true}), current = await fs.lstat(file, {bigint: true}); + if (!same(after, opened) || !same(current, opened) || after.size !== opened.size || after.mtimeNs !== opened.mtimeNs || after.ctimeNs !== opened.ctimeNs || current.isSymbolicLink() || current.nlink !== 1n) throw editFailure('conflict'); + return bytes; + } finally {await handle.close();} +} +const decode = bytes => JSON.parse(new TextDecoder('utf-8', {fatal: true}).decode(bytes)); +function owner(input) { + exactObject(input, ['pid', 'host', 'token']); requireUuid(input.token); + if (!Number.isSafeInteger(input.pid) || input.pid < 1 || typeof input.host !== 'string' || !input.host || input.host.length > 256) throw editFailure('invalid_request'); + return input; +} +function definitelyDead(value) { + if (value.host !== hostname() || value.pid === process.pid) return false; + try {process.kill(value.pid, 0); return false;} catch (error) {return error?.code === 'ESRCH';} +} +function unpack(bytes) { + const envelope = decode(bytes); exactObject(envelope, ['formatVersion', 'sha256', 'record']); + if (envelope.formatVersion !== 1) throw editFailure('unsupported'); + const record = parseWorkspaceEditRecord(envelope.record); + if (envelope.sha256 !== editHash(canonicalEditJson(record))) throw editFailure('conflict'); + return record; +} + +/** Operator-owned local storage, outside the workspace, with one writer. */ +export async function createNodeWorkspaceEditJournal({directory, workspaceRoot, workspaceId, recoverStaleLock = false} = {}) { + requireUuid(workspaceId); + if (typeof recoverStaleLock !== 'boolean' || typeof directory !== 'string' || !path.isAbsolute(directory)) throw editFailure('invalid_request'); + let root, initial, workspaceBinding, lockToken, closed = false, closing = false, tail = Promise.resolve(), queued = 0, closePromise; + const owned = new Map(), sizes = new Map(), directories = new Map(); let totalBytes = 0; + const currentOwner = {pid: process.pid, host: hostname(), token: randomUUID()}; + async function checkRoot(checkWorkspace = true) { + const current = await realDirectory(root); + if (current.canonical !== root || !same(current.stat, initial)) throw editFailure('conflict'); + if (checkWorkspace) { + const workspace = await realDirectory(workspaceRoot); + if (nodeWorkspaceIdentity(workspace.canonical, workspace.stat) !== workspaceBinding) throw editFailure('workspace_mismatch'); + } + } + async function checkOwned(checkWorkspace = true) { + if (closed) throw editFailure('disposed'); + await checkRoot(checkWorkspace); + const writer = owner(decode(await regularBytes(path.join(root, '.writer.lock'), 2048))); + if (writer.token !== lockToken || writer.pid !== process.pid || writer.host !== hostname()) throw editFailure('conflict'); + } + async function syncDirectory(target) { + if (process.platform === 'win32') return; + const handle = await fs.open(target, flags.O_RDONLY | (flags.O_NOFOLLOW ?? 0)); + try {await handle.sync();} finally {await handle.close();} + } + async function exclusive(file, bytes) { + const handle = await fs.open(file, flags.O_WRONLY | flags.O_CREAT | flags.O_EXCL | (flags.O_NOFOLLOW ?? 0), 0o600); + try {await handle.writeFile(bytes); await handle.sync();} finally {await handle.close();} + } + async function acquire() { + const lock = path.join(root, '.writer.lock'), recovery = path.join(root, '.recovery.lock'); + try {await fs.lstat(recovery); throw editFailure('conflict');} catch (error) {if (error.code !== 'ENOENT') throw error;} + try {await exclusive(lock, JSON.stringify(currentOwner)); lockToken = currentOwner.token;} + catch (error) { + if (error.code !== 'EEXIST' || !recoverStaleLock) throw error; + const previous = owner(decode(await regularBytes(lock, 2048))); + if (!definitelyDead(previous)) throw editFailure('conflict'); + await exclusive(recovery, JSON.stringify(currentOwner)); + try { + await checkRoot(); const confirmed = owner(decode(await regularBytes(lock, 2048))); + if (confirmed.token !== previous.token || !definitelyDead(confirmed)) throw editFailure('conflict'); + await fs.unlink(lock); await exclusive(lock, JSON.stringify(currentOwner)); lockToken = currentOwner.token; + } finally { + const guard = owner(decode(await regularBytes(recovery, 2048))); + if (guard.token !== currentOwner.token) throw editFailure('conflict'); + await fs.unlink(recovery); + } + } + try {await fs.lstat(recovery); throw editFailure('conflict');} catch (error) {if (error.code !== 'ENOENT') throw error;} + await syncDirectory(root); + } + async function checkPlan(id) { + requireUuid(id); await checkOwned(); + const target = path.join(root, id), inspected = await realDirectory(target); + if (inspected.canonical !== target || directories.has(id) && !same(inspected.stat, directories.get(id))) throw editFailure('conflict'); + directories.set(id, inspected.stat); return target; + } + async function read(id) { + const target = await checkPlan(id), bytes = await regularBytes(path.join(target, 'record.json')); + const record = unpack(bytes); + if (record.preview.planId !== id || record.preview.workspace.id !== workspaceId) throw editFailure('workspace_mismatch'); + await checkOwned(); return record; + } + async function save(record, beginning) { + await checkOwned(); record = parseWorkspaceEditRecord(record); + const id = record.preview.planId; + if (record.preview.workspace.id !== workspaceId) throw editFailure('workspace_mismatch'); + if (beginning && (record.revision !== 1 || record.phase !== 'applying' || record.steps.some(step => step.state !== 'pending'))) throw editFailure('invalid_request'); + if (!beginning) { + const previous = owned.get(id); + if (!previous || record.revision !== previous.revision + 1 || record.preview.digest !== previous.digest) throw editFailure('conflict'); + const stored = await read(id); + if (stored.revision !== previous.revision || editHash(canonicalEditJson(stored)) !== previous.sha256) throw editFailure('conflict'); + } + const sha256 = editHash(canonicalEditJson(record)), bytes = Buffer.from(JSON.stringify({formatVersion: 1, sha256, record})); + if (bytes.length > maximum || totalBytes - (sizes.get(id) ?? 0) + bytes.length > WORKSPACE_EDIT_LIMITS.journalBytes || beginning && sizes.size >= WORKSPACE_EDIT_LIMITS.journalRecords) throw editFailure('budget_exceeded'); + let target; + if (beginning) { + target = path.join(root, id); await fs.mkdir(target, {mode: 0o700}); + const inspected = await realDirectory(target); + if (inspected.canonical !== target) throw editFailure('conflict'); + directories.set(id, inspected.stat); sizes.set(id, 0); await syncDirectory(root); + } else target = await checkPlan(id); + const temporary = path.join(target, '.checkpoint-' + randomUUID()); let created = false; + try { + await exclusive(temporary, bytes); created = true; await checkPlan(id); + const file = path.join(target, 'record.json'); + if (beginning) {try {await fs.lstat(file); throw editFailure('conflict');} catch (error) {if (error.code !== 'ENOENT') throw error;}} + else await regularBytes(file); + await fs.rename(temporary, file); created = false; await syncDirectory(target); await checkOwned(); + const stored = await read(id); + if (editHash(canonicalEditJson(stored)) !== sha256) throw editFailure('conflict'); + totalBytes += bytes.length - (sizes.get(id) ?? 0); sizes.set(id, bytes.length); + owned.set(id, {revision: record.revision, digest: record.preview.digest, sha256}); + } finally { + if (created) {await checkPlan(id); await regularBytes(temporary); await fs.unlink(temporary);} + } + } + function schedule(operation) { + if (closed || closing) return Promise.reject(editFailure('disposed')); + if (queued >= 64) return Promise.reject(editFailure('budget_exceeded')); + queued++; + const result = tail.then(operation).catch(error => {throw mapped(error);}).finally(() => {queued--;}); + tail = result.catch(() => {}); return result; + } + async function release() { + if (!lockToken) return; + await checkOwned(false); await fs.unlink(path.join(root, '.writer.lock')); lockToken = undefined; await syncDirectory(root); + } + try { + const workspace = await realDirectory(workspaceRoot); workspaceBinding = nodeWorkspaceIdentity(workspace.canonical, workspace.stat); + const parent = await realDirectory(path.dirname(directory)), requested = path.join(parent.canonical, path.basename(directory)); + if (contained(workspace.canonical, requested) || contained(requested, workspace.canonical)) throw editFailure('unsafe_path'); + try {await fs.mkdir(requested, {mode: 0o700});} catch (error) {if (error.code !== 'EEXIST') throw error;} + const created = await realDirectory(requested); root = created.canonical; initial = created.stat; + if (root !== requested) throw editFailure('unsafe_path'); + await acquire(); + const identityPath = path.join(root, 'identity.json'), identity = {formatVersion: 1, workspaceId, workspaceIdentity: workspaceBinding}; + try { + const stored = decode(await regularBytes(identityPath, 2048)); + if (canonicalEditJson(editCopy(stored, 2048)) !== canonicalEditJson(identity)) throw editFailure('workspace_mismatch'); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + if ((await fs.readdir(root)).some(name => name !== '.writer.lock')) throw editFailure('conflict'); + await exclusive(identityPath, JSON.stringify(identity)); await syncDirectory(root); + } + const entries = await fs.readdir(root, {withFileTypes: true}); + if (entries.length > WORKSPACE_EDIT_LIMITS.journalRecords + 2) throw editFailure('budget_exceeded'); + for (const entry of entries) { + if (['identity.json', '.writer.lock'].includes(entry.name)) continue; + if (!isId(entry.name) || !entry.isDirectory() || entry.isSymbolicLink()) throw editFailure('unsafe_path'); + const target = await checkPlan(entry.name); let size = 0; + for (const file of await fs.readdir(target, {withFileTypes: true})) { + if (file.name !== 'record.json' && !/^\.checkpoint-[a-f0-9-]{36}$/.test(file.name)) throw editFailure('unsafe_path'); + size += (await regularBytes(path.join(target, file.name))).length; + } + totalBytes += size; sizes.set(entry.name, size); + } + if (totalBytes > WORKSPACE_EDIT_LIMITS.journalBytes) throw editFailure('budget_exceeded'); + } catch (error) {try {await release();} catch {} closed = true; throw mapped(error);} + return Object.freeze({ + capabilities: Object.freeze({formatVersion: 1, workspaceId, durability: 'file-fsync-rename', directoryFsync: process.platform !== 'win32', singleWriter: true, limits: WORKSPACE_EDIT_LIMITS}), + begin(record) {let valid; try {valid = parseWorkspaceEditRecord(record);} catch (error) {return Promise.reject(mapped(error));} return schedule(() => save(valid, true));}, + write(record) {let valid; try {valid = parseWorkspaceEditRecord(record);} catch (error) {return Promise.reject(mapped(error));} return schedule(() => save(valid, false));}, + read(id) {try {requireUuid(id);} catch (error) {return Promise.reject(mapped(error));} return schedule(() => read(id));}, + list() {return schedule(async () => {await checkOwned(); const items = []; for (const id of sizes.keys()) {try {const record = await read(id); items.push({planId: id, phase: record.phase, revision: record.revision, available: true});} catch (error) {items.push({planId: id, available: false, errorCode: mapped(error).code});}} return editCopy(items);});}, + close() {if (closePromise) return closePromise; closing = true; closePromise = tail.then(release).catch(error => {throw mapped(error);}).finally(() => {closed = true;}); return closePromise;}, + }); +} diff --git a/src/workspace-edits.d.mts b/src/workspace-edits.d.mts new file mode 100644 index 0000000..0b58ee9 --- /dev/null +++ b/src/workspace-edits.d.mts @@ -0,0 +1,65 @@ +import type {WorkspaceClient, WorkspaceRequestOptions} from './workspace-client.mjs'; +import type {TextEdit} from './workspace-project.mjs'; +import type {WorkspaceErrorCode} from './workspace-protocol.mjs'; + +export const WORKSPACE_EDIT_LIMITS: Readonly<{changes: 32; edits: 1000; proposalBytes: 1048576; previewBytes: 8388608; journalRecords: 64; journalBytes: 67108864}>; +export const WORKSPACE_EDIT_CAPABILITIES: Readonly<{formatVersion: 1; kinds: readonly ['edit', 'create', 'move', 'delete']; atomicity: 'per-file'; recovery: 'readback-only'; limits: typeof WORKSPACE_EDIT_LIMITS}>; +export type WorkspaceEditChange = {readonly reason?: string} & ( + {readonly kind: 'edit'; readonly path: string; readonly baseRevision: string; readonly edits: readonly TextEdit[]} | + {readonly kind: 'create'; readonly path: string; readonly content: string} | + {readonly kind: 'move'; readonly path: string; readonly newPath: string; readonly baseRevision: string} | + {readonly kind: 'delete'; readonly path: string; readonly baseRevision: string} +); +export interface WorkspaceEdit {readonly formatVersion: 1; readonly id: string; readonly title: string; readonly changes: readonly WorkspaceEditChange[];} +export interface WorkspaceEditFileState {readonly content: string; readonly revision: string;} +export interface WorkspaceEditPathState {readonly path: string; readonly state: WorkspaceEditFileState | null;} +export interface WorkspaceEditPreviewStep { + readonly index: number; readonly before: readonly WorkspaceEditPathState[]; readonly after: readonly WorkspaceEditPathState[] | null; + /** One UTF-16 replacement hunk. Full before/after snapshots remain available above. */ + readonly diff: readonly {readonly path: string; readonly change: {readonly start: number; readonly deleteCount: number; readonly insertText: string}}[]; +} +export interface WorkspaceEditPreview { + readonly formatVersion: 1; readonly planId: string; readonly digest: string; + readonly workspace: {readonly id: string; readonly generation: string}; readonly edit: WorkspaceEdit; + readonly requiredCapabilities: readonly ('write' | 'manage')[]; readonly ready: boolean; + readonly conflicts: readonly {readonly path: string; readonly code: 'destination_exists' | 'source_missing' | 'revision_changed' | 'no_change'}[]; + readonly steps: readonly WorkspaceEditPreviewStep[]; +} +export type WorkspaceEditObservedState = 'applied' | 'not-applied' | 'conflicted' | 'unknown'; +export interface WorkspaceEditRecord { + readonly formatVersion: 1; readonly preview: WorkspaceEditPreview; readonly revision: number; + readonly phase: 'applying' | 'completed' | 'stopped'; readonly updatedAt: number; + readonly steps: readonly {readonly state: WorkspaceEditObservedState | 'pending' | 'intent'; readonly errorCode?: WorkspaceErrorCode}[]; +} +/** A trusted host port. Writes must persist before resolving; begin must reject reused plan IDs. */ +export interface WorkspaceEditJournal { + begin(record: WorkspaceEditRecord): Promise; + write(record: WorkspaceEditRecord): Promise; + read(planId: string): Promise; +} +export interface WorkspaceEditReceipt { + readonly formatVersion: 1; readonly planId: string; readonly digest: string; readonly workspaceId: string; + readonly status: 'completed' | 'partial' | 'not-applied' | 'uncertain'; + readonly steps: readonly {readonly index: number; readonly state: WorkspaceEditObservedState; readonly canCompensate: boolean; readonly errorCode?: WorkspaceErrorCode}[]; + readonly executionStopped?: boolean; readonly errorCode?: WorkspaceErrorCode; readonly journalError?: WorkspaceErrorCode; + readonly currentGeneration?: string; readonly journalPhase?: WorkspaceEditRecord['phase']; readonly journalRevision?: number; +} +export interface WorkspaceEditAuthorization { + readonly planId: string; readonly digest: string; readonly workspace: WorkspaceEditPreview['workspace']; + readonly requiredCapabilities: WorkspaceEditPreview['requiredCapabilities']; + readonly phase: 'preflight' | 'before-change' | 'apply'; readonly index: number | null; +} +export interface WorkspaceEditApplyOptions extends WorkspaceRequestOptions { + readonly reviewed: {readonly planId: string; readonly digest: string}; readonly journal: WorkspaceEditJournal; + /** Current host policy for the exact reviewed plan, checked again for each operation. */ + readonly authorize: (request: WorkspaceEditAuthorization, options: {readonly signal?: AbortSignal}) => boolean | Promise; +} +export function parseWorkspaceEdit(value: unknown): WorkspaceEdit; +export function parseWorkspaceEditPreview(value: unknown): WorkspaceEditPreview; +export function parseWorkspaceEditRecord(value: unknown): WorkspaceEditRecord; +export function parseWorkspaceEditReceipt(value: unknown): WorkspaceEditReceipt; +export function previewWorkspaceEdit(client: WorkspaceClient, edit: WorkspaceEdit, options?: WorkspaceRequestOptions): Promise; +export function applyWorkspaceEdit(client: WorkspaceClient, preview: WorkspaceEditPreview, options: WorkspaceEditApplyOptions): Promise; +/** This is observational recovery, not a resume or retry operation. */ +export function recoverWorkspaceEdit(client: WorkspaceClient, journal: WorkspaceEditJournal, planId: string, options?: WorkspaceRequestOptions): Promise; +export function createWorkspaceCompensation(preview: WorkspaceEditPreview, receipt: WorkspaceEditReceipt, options: {readonly id: string; readonly title?: string}): WorkspaceEdit | null; diff --git a/src/workspace-edits.mjs b/src/workspace-edits.mjs new file mode 100644 index 0000000..d18d719 --- /dev/null +++ b/src/workspace-edits.mjs @@ -0,0 +1,170 @@ +import {exactObject, requireUuid, requireSha256, WORKSPACE_ERROR_CODES} from './workspace-values.mjs'; +import {textIndex} from './language-values.mjs'; +import {parseWorkspaceEdit, parseWorkspaceEditPreview, parseWorkspaceEditRecord, parseWorkspaceEditReceipt, buildEditPreview, changePaths, parseEditFileState, editCopy, canonicalEditJson, editFailure} from './workspace-edit-contracts.mjs'; +export {WORKSPACE_EDIT_LIMITS, WORKSPACE_EDIT_CAPABILITIES, parseWorkspaceEdit, parseWorkspaceEditPreview, parseWorkspaceEditRecord, parseWorkspaceEditReceipt} from './workspace-edit-contracts.mjs'; + +const active = new WeakSet(); +const activeJournals = new WeakSet(); +const codeOf = error => WORKSPACE_ERROR_CODES.includes(error?.code) ? error.code : 'provider_failed'; +function optionsCheck(options, fields = []) { + exactObject(options, [], ['signal', 'timeoutMs', ...fields]); + if (options.signal !== undefined && !(options.signal instanceof AbortSignal)) throw editFailure('invalid_request'); + if (options.timeoutMs !== undefined && (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 1 || options.timeoutMs > 30_000)) throw editFailure('invalid_request'); + return {signal: options.signal, timeoutMs: options.timeoutMs}; +} +function check(client, expected, signal, allowNewGeneration = false) { + if (signal?.aborted) throw editFailure('cancelled'); + const binding = client?.binding; + if (!binding || typeof client.readFile !== 'function') throw editFailure('unavailable'); + const workspace = {id: binding.workspace.id, generation: binding.workspace.generation}; + requireUuid(workspace.id); requireUuid(workspace.generation); + if (expected && workspace.id !== expected.id) throw editFailure('workspace_mismatch'); + if (expected && !allowNewGeneration && workspace.generation !== expected.generation) throw editFailure('generation_mismatch'); + return workspace; +} +async function readState(client, path, expected, options, allowNewGeneration = false) { + check(client, expected, options.signal, allowNewGeneration); + let state; + try { + const file = await client.readFile(path, options); + exactObject(file, ['path', 'content', 'revision']); + if (file.path !== path) throw editFailure('conflict'); + state = parseEditFileState({content: file.content, revision: file.revision}); + } catch (error) {if (error?.code !== 'not_found') throw error; state = null;} + check(client, expected, options.signal, allowNewGeneration); + return state; +} +export async function previewWorkspaceEdit(client, input, options = {}) { + const request = optionsCheck(options), edit = parseWorkspaceEdit(input), workspace = check(client, undefined, request.signal), originals = []; + for (const path of edit.changes.flatMap(changePaths)) originals.push({path, state: await readState(client, path, workspace, request)}); + return buildEditPreview(edit, workspace, originals); +} +async function authorize(client, preview, options, phase, index) { + check(client, preview.workspace, options.signal); + if (typeof options.authorize !== 'function') throw editFailure('permission_denied'); + const controller = new AbortController(); + const abort = () => controller.abort(editFailure('cancelled')); + options.signal?.addEventListener('abort', abort, {once: true}); + let onAbort, approved; + const stopped = new Promise((_, reject) => {onAbort = () => reject(controller.signal.reason); controller.signal.addEventListener('abort', onAbort, {once: true});}); + const timer = setTimeout(() => controller.abort(editFailure('budget_exceeded')), options.timeoutMs ?? 5000); + try { + approved = await Promise.race([Promise.resolve().then(() => options.authorize(editCopy({planId: preview.planId, digest: preview.digest, workspace: preview.workspace, requiredCapabilities: preview.requiredCapabilities, phase, index}), {signal: controller.signal})), stopped]); + } finally {clearTimeout(timer); options.signal?.removeEventListener('abort', abort); controller.signal.removeEventListener('abort', onAbort);} + check(client, preview.workspace, options.signal); + if (approved !== true || preview.requiredCapabilities.some(capability => client.binding.capabilities[capability] !== true)) throw editFailure('permission_denied'); +} +const equalState = (a, b) => a === null || b === null ? a === b : a.revision === b.revision; +async function preflight(client, preview, request) { + for (const step of preview.steps) for (const original of step.before) if (!equalState(await readState(client, original.path, preview.workspace, request), original.state)) throw editFailure('conflict'); +} +async function observeStep(client, preview, step, request, allowNewGeneration = false) { + try { + const states = []; + for (const file of step.before) states.push(await readState(client, file.path, preview.workspace, request, allowNewGeneration)); + if (states.every((state, n) => equalState(state, step.after[n].state))) return {state: 'applied'}; + if (states.every((state, n) => equalState(state, step.before[n].state))) return {state: 'not-applied'}; + return {state: 'conflicted'}; + } catch (error) {return {state: 'unknown', errorCode: codeOf(error)};} +} +function receipt(preview, observations, details = {}) { + const applied = observations.filter(step => step.state === 'applied').length; + const uncertain = observations.some(step => ['unknown', 'conflicted', 'intent'].includes(step.state)); + return parseWorkspaceEditReceipt({formatVersion: 1, planId: preview.planId, digest: preview.digest, workspaceId: preview.workspace.id, + status: uncertain ? 'uncertain' : applied === observations.length ? 'completed' : applied ? 'partial' : 'not-applied', + steps: observations.map((step, index) => ({index, ...step, canCompensate: step.state === 'applied'})), ...details}); +} +async function perform(client, change, step, request) { + if (change.kind === 'edit') await client.writeFile(change.path, step.after[0].state.content, change.baseRevision, request); + else if (change.kind === 'create') await client.writeFile(change.path, change.content, null, request); + else if (change.kind === 'move') await client.rename(change.path, change.newPath, change.baseRevision, request); + else await client.remove(change.path, change.baseRevision, request); +} +export async function applyWorkspaceEdit(client, input, options = {}) { + const request = optionsCheck(options, ['reviewed', 'authorize', 'journal']), preview = parseWorkspaceEditPreview(input); + exactObject(options.reviewed, ['planId', 'digest']); requireUuid(options.reviewed.planId); requireSha256(options.reviewed.digest); + if (options.reviewed.planId !== preview.planId || options.reviewed.digest !== preview.digest || !preview.ready) throw editFailure('conflict'); + const journal = options.journal; + if (!journal || typeof journal.begin !== 'function' || typeof journal.write !== 'function' || typeof journal.read !== 'function') throw editFailure('invalid_request'); + check(client, preview.workspace, request.signal); + if (active.has(client) || activeJournals.has(journal)) throw editFailure('conflict'); active.add(client); activeJournals.add(journal); + try { + await authorize(client, preview, options, 'preflight', null); await preflight(client, preview, request); + let record = parseWorkspaceEditRecord({formatVersion: 1, preview, revision: 1, phase: 'applying', steps: preview.steps.map(() => ({state: 'pending'})), updatedAt: Date.now()}); + const verifyJournal = async () => { + const saved = parseWorkspaceEditRecord(await journal.read(preview.planId)); + if (canonicalEditJson(saved) !== canonicalEditJson(record)) throw editFailure('conflict'); + }; + await journal.begin(record); await verifyJournal(); + const observations = preview.steps.map(() => ({state: 'not-applied'})); + async function checkpoint(index, state, phase = 'applying') { + record = parseWorkspaceEditRecord({...record, revision: record.revision + 1, phase, updatedAt: Date.now(), steps: record.steps.map((step, n) => n === index ? state : step)}); + await journal.write(record); await verifyJournal(); + } + for (let index = 0; index < preview.steps.length; index++) { + const step = preview.steps[index], change = preview.edit.changes[index]; + let operationStarted = false; + try { + await authorize(client, preview, options, 'before-change', index); + for (const original of step.before) if (!equalState(await readState(client, original.path, preview.workspace, request), original.state)) throw editFailure('conflict'); + await checkpoint(index, {state: 'intent'}); + await authorize(client, preview, options, 'apply', index); + operationStarted = true; + await perform(client, change, step, request); + observations[index] = await observeStep(client, preview, step, request); + if (observations[index].state !== 'applied') throw editFailure(observations[index].errorCode ?? 'conflict'); + await checkpoint(index, {state: 'applied'}, index === preview.steps.length - 1 ? 'completed' : 'applying'); + } catch (error) { + if (operationStarted) { + // Do not ignore cancellation or reconnect to manufacture a successful + // readback. A caller can explicitly recover with fresh authority later. + observations[index] = await observeStep(client, preview, step, request); + if (observations[index].state === 'not-applied' && ['transport_failed', 'cancelled', 'budget_exceeded', 'unavailable'].includes(codeOf(error))) observations[index] = {state: 'unknown', errorCode: codeOf(error)}; + } + let journalError; + try {await checkpoint(index, {...observations[index], errorCode: codeOf(error)}, 'stopped');} + catch (failure) {journalError = codeOf(failure);} + return receipt(preview, observations, {executionStopped: true, errorCode: codeOf(error), ...(journalError ? {journalError} : {})}); + } + } + return receipt(preview, observations, {executionStopped: false}); + } finally {active.delete(client); activeJournals.delete(journal);} +} + +/** Read current file states; never replay an old journal or change a file. */ +export async function recoverWorkspaceEdit(client, journal, planId, options = {}) { + const request = optionsCheck(options); requireUuid(planId); + if (!journal || typeof journal.read !== 'function') throw editFailure('invalid_request'); + if (active.has(client) || activeJournals.has(journal)) throw editFailure('conflict'); + const record = parseWorkspaceEditRecord(await journal.read(planId)); + if (record.preview.planId !== planId) throw editFailure('conflict'); + const workspace = check(client, record.preview.workspace, request.signal, true), observations = []; + for (const step of record.preview.steps) { + const current = await observeStep(client, record.preview, step, request, true); + // A durable applied receipt followed by original bytes is an external change, + // not proof that the operation was never applied. + if (record.steps[step.index].state === 'applied' && current.state === 'not-applied') current.state = 'conflicted'; + observations.push(current); + } + return receipt(record.preview, observations, {currentGeneration: workspace.generation, journalPhase: record.phase, journalRevision: record.revision}); +} + +/** Build a new proposal only. Its preview, current CAS and approval remain mandatory. */ +export function createWorkspaceCompensation(input, recovery, {id, title = 'Restore reviewed workspace changes'} = {}) { + const preview = parseWorkspaceEditPreview(input), value = parseWorkspaceEditReceipt(recovery); requireUuid(id); if (id === preview.planId) throw editFailure('conflict'); + if (value.planId !== preview.planId || value.digest !== preview.digest || value.workspaceId !== preview.workspace.id || !Array.isArray(value.steps) || value.steps.length !== preview.steps.length) throw editFailure('conflict'); + const changes = []; + for (let n = preview.steps.length - 1; n >= 0; n--) { + if (value.steps[n].index !== n) throw editFailure('conflict'); + if (value.steps[n].state !== 'applied') continue; + const step = preview.steps[n], change = preview.edit.changes[n]; + if (change.kind === 'create') changes.push({kind: 'delete', path: change.path, baseRevision: step.after[0].state.revision}); + else if (change.kind === 'delete') changes.push({kind: 'create', path: change.path, content: step.before[0].state.content}); + else if (change.kind === 'move') changes.push({kind: 'move', path: change.newPath, newPath: change.path, baseRevision: step.after[1].state.revision}); + else { + const content = step.after[0].state.content; + changes.push({kind: 'edit', path: change.path, baseRevision: step.after[0].state.revision, edits: [{range: {start: {line: 0, character: 0}, end: textIndex(content).position(content.length)}, text: step.before[0].state.content}]}); + } + } + return changes.length ? parseWorkspaceEdit({formatVersion: 1, id, title, changes}) : null; +} diff --git a/tests/types/workspace-edits.mts b/tests/types/workspace-edits.mts new file mode 100644 index 0000000..d550516 --- /dev/null +++ b/tests/types/workspace-edits.mts @@ -0,0 +1,27 @@ +import {createPluginHost, createLanguageResult, createLanguageRegistry} from '@altifigence/dds-plugin-sdk'; +import {createWorkspaceClient} from '@altifigence/dds-plugin-sdk/workspace-client'; +import {previewWorkspaceEdit, applyWorkspaceEdit, recoverWorkspaceEdit, createWorkspaceCompensation, parseWorkspaceEditReceipt, type WorkspaceEdit} from '@altifigence/dds-plugin-sdk/workspace-edits'; +import {createNodeWorkspaceEditJournal} from '@altifigence/dds-plugin-sdk/workspace-edits-node'; +const client = createWorkspaceClient({url: 'http://127.0.0.1:1234', token: 'operator-selected-token-0123456789'}); +const journal = await createNodeWorkspaceEditJournal({directory: '/operator/journal', workspaceRoot: '/operator/project', workspaceId: crypto.randomUUID()}); +const proposal: WorkspaceEdit = {formatVersion: 1, id: crypto.randomUUID(), title: 'Create a file', changes: [{kind: 'create', path: 'new.txt', content: 'new'}]}; +const preview = await previewWorkspaceEdit(client, proposal); +const applied = await applyWorkspaceEdit(client, preview, {journal, reviewed: {planId: preview.planId, digest: preview.digest}, authorize(request, {signal}) {signal?.throwIfAborted(); return request.requiredCapabilities.includes('write');}}); +const recovery = parseWorkspaceEditReceipt(await recoverWorkspaceEdit(client, journal, preview.planId)); +const inverse: WorkspaceEdit | null = createWorkspaceCompensation(preview, recovery, {id: crypto.randomUUID()}); +const host = createPluginHost(), prep = await host.requestLanguage('prepare-rename', {position: {line: 0, character: 0}}); +const placeholder: string | undefined = prep.data?.placeholder; +const renamed = await host.requestLanguage('rename', {position: {line: 0, character: 0}, newName: 'name'}); +const suggested: WorkspaceEdit | null = renamed.data; +const registry = createLanguageRegistry('rename'); registry.register('typed', {languages: ['plain']}, {provide: request => createLanguageResult(request, proposal)}); +// @ts-expect-error Rename needs a new name. +host.requestLanguage('rename', {position: {line: 0, character: 0}}); +// @ts-expect-error Prepare rename does not accept a new name. +host.requestLanguage('prepare-rename', {position: {line: 0, character: 0}, newName: 'name'}); +// @ts-expect-error Writes need both current authorization and the reviewed digest. +applyWorkspaceEdit(client, preview, {journal}); +// @ts-expect-error Existing-file edits require a pinned revision. +const invalid: WorkspaceEdit = {formatVersion: 1, id: crypto.randomUUID(), title: 'Edit', changes: [{kind: 'delete', path: 'old.txt'}]}; +// @ts-expect-error Preview file snapshots are immutable. +preview.steps[0]!.before[0]!.state!.content = 'changed'; +void applied; void inverse; void placeholder; void suggested; void invalid; await journal.close(); diff --git a/tests/workspace-edits.test.mjs b/tests/workspace-edits.test.mjs new file mode 100644 index 0000000..b4eeb5a --- /dev/null +++ b/tests/workspace-edits.test.mjs @@ -0,0 +1,199 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import {randomUUID} from 'node:crypto'; +import {createWorkspaceServer} from '../src/workspace-node.mjs'; +import {createWorkspaceClient} from '../src/workspace-client.mjs'; +import {parseWorkspaceEdit, parseWorkspaceEditPreview, previewWorkspaceEdit, applyWorkspaceEdit, recoverWorkspaceEdit, createWorkspaceCompensation} from '../src/workspace-edits.mjs'; +import {createNodeWorkspaceEditJournal} from '../src/workspace-edits-node.mjs'; +import {editHash, textDifference} from '../src/workspace-edit-contracts.mjs'; +import {parseLanguageRequest, createLanguageResult, createLanguageRegistry} from '../src/index.mjs'; +import {deferred} from './fixtures.mjs'; + +const token = 'workspace-edit-test-operator-token-0123456789'; +const edit = (start, end, text, line = 0) => ({range: {start: {line, character: start}, end: {line, character: end}}, text}); +const proposal = changes => ({formatVersion: 1, id: randomUUID(), title: 'Reviewed change', changes}); +const reviewed = preview => ({planId: preview.planId, digest: preview.digest}); +const memoryJournal = () => { + const data = new Map(); + return {async begin(record) {if (data.has(record.preview.planId)) throw Object.assign(new Error(), {code: 'conflict'}); data.set(record.preview.planId, record);}, async write(record) {data.set(record.preview.planId, record);}, async read(id) {return data.get(id);}}; +}; +async function fixture(t, options = {}) { + const parent = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'dds-edits-test-'))), root = path.join(parent, 'workspace'), workspaceId = randomUUID(); + await fs.mkdir(root); await fs.writeFile(path.join(root, 'first.txt'), 'alpha 😀\r\nnext'); await fs.writeFile(path.join(root, 'second.txt'), 'alpha second'); + const server = await createWorkspaceServer({root, workspaceId, token, notice: {id: 'edits-test', version: '1', text: 'Disposable test'}, ...options}); + const client = createWorkspaceClient({url: server.url, token}); await client.connect(); + const journals = []; + const openJournal = async (overrides = {}) => {const journal = await createNodeWorkspaceEditJournal({directory: path.join(parent, 'journal'), workspaceRoot: root, workspaceId, ...overrides}); journals.push(journal); return journal;}; + t.after(async () => {for (const journal of journals) await journal.close(); client.dispose(); await server.close(); assert.equal(path.dirname(parent), await fs.realpath(os.tmpdir())); assert.ok(path.basename(parent).startsWith('dds-edits-test-')); await fs.rm(parent, {recursive: true});}); + return {parent, root, workspaceId, client, server, openJournal}; +} +async function editFirst(client, text = 'beta') { + const file = await client.readFile('first.txt'); + return proposal([{kind: 'edit', path: file.path, baseRevision: file.revision, edits: [edit(0, 5, text)]}]); +} + +test('proposal and preview reject unsafe, duplicate, dependent and invalid UTF-16 edits', async t => { + const {client} = await fixture(t); + for (const target of ['../outside', '.env', '.git/config', 'x/secret.key', 'C:/outside', 'link\\file']) assert.throws(() => parseWorkspaceEdit(proposal([{kind: 'create', path: target, content: ''}])), {code: 'unsafe_path'}); + for (const paths of [['a', 'A'], ['a', 'a-b', 'a/b']]) assert.throws(() => parseWorkspaceEdit(proposal(paths.map(path => ({kind: 'create', path, content: ''})))), {code: 'invalid_request'}); + const p = await editFirst(client); + for (const edits of [[edit(0, 5, 'x'), edit(4, 6, 'y')], [edit(7, 7, '')], [edit(0, 5, 'alpha')]]) { + const changed = {...p, changes: [{...p.changes[0], edits}]}; + if (edits[0].text === 'alpha') assert.equal((await previewWorkspaceEdit(client, changed)).conflicts[0].code, 'no_change'); + else await assert.rejects(previewWorkspaceEdit(client, changed), {code: 'invalid_request'}); + } + const preview = await previewWorkspaceEdit(client, p), changed = structuredClone(preview); changed.steps[0].after[0].state.content = 'wrong'; + assert.throws(() => parseWorkspaceEditPreview(changed), {code: 'conflict'}); + assert.equal(preview.steps[0].after[0].state.content, 'beta 😀\r\nnext'); + const diff = textDifference('a😀\r\nx', 'a😁\r\ny'); + assert.equal('a😀\r\nx'.slice(0, diff.start) + diff.insertText + 'a😀\r\nx'.slice(diff.start + diff.deleteCount), 'a😁\r\ny'); +}); + +test('real HTTP apply uses reviewed digests, persistent intent, CAS and independently reviewed compensation', async t => { + const {client, openJournal} = await fixture(t), journal = await openJournal(); + const first = await client.readFile('first.txt'), second = await client.readFile('second.txt'); + const p = proposal([ + {kind: 'edit', path: 'first.txt', baseRevision: first.revision, edits: [edit(0, 5, 'beta')]}, + {kind: 'move', path: 'second.txt', newPath: 'moved.txt', baseRevision: second.revision}, + {kind: 'create', path: 'created.txt', content: 'created'}, + ]); + const preview = await previewWorkspaceEdit(client, p); + assert.deepEqual(preview.requiredCapabilities, ['write', 'manage']); + await assert.rejects(applyWorkspaceEdit(client, preview, {journal, reviewed: {...reviewed(preview), digest: '0'.repeat(64)}, authorize: () => true}), {code: 'conflict'}); + const approvals = []; + const applied = await applyWorkspaceEdit(client, preview, {journal, reviewed: reviewed(preview), authorize: info => {approvals.push(info.phase); return true;}}); + assert.equal(applied.status, 'completed'); assert.equal(applied.executionStopped, false); + assert.equal((await journal.read(p.id)).phase, 'completed'); assert.ok(approvals.includes('apply')); + assert.equal((await client.readFile('first.txt')).content, 'beta 😀\r\nnext'); + await assert.rejects(client.readFile('second.txt'), {code: 'not_found'}); + const recovery = await recoverWorkspaceEdit(client, journal, p.id); assert.equal(recovery.status, 'completed'); + const rollback = createWorkspaceCompensation(preview, recovery, {id: randomUUID()}); + assert.deepEqual(rollback.changes.map(change => change.kind), ['delete', 'move', 'edit']); + const restorePreview = await previewWorkspaceEdit(client, rollback); + assert.equal((await applyWorkspaceEdit(client, restorePreview, {journal, reviewed: reviewed(restorePreview), authorize: () => true})).status, 'completed'); + assert.equal((await client.readFile('first.txt')).content, first.content); assert.equal((await client.readFile('second.txt')).content, second.content); + assert.equal((await recoverWorkspaceEdit(client, journal, p.id)).status, 'uncertain'); + assert.ok((await journal.list()).every(item => item.available)); + await journal.close(); + const reopened = await openJournal(); assert.equal((await reopened.read(p.id)).phase, 'completed'); + await assert.rejects(reopened.begin(await reopened.read(p.id)), {code: 'invalid_request'}); +}); + +test('preflight checks every file and move destination before changing any file', async t => { + const {client, root} = await fixture(t), first = await client.readFile('first.txt'), second = await client.readFile('second.txt'); + const p = proposal([{kind: 'edit', path: first.path, baseRevision: first.revision, edits: [edit(0, 5, 'beta')]}, {kind: 'move', path: second.path, newPath: 'moved.txt', baseRevision: second.revision}]); + const preview = await previewWorkspaceEdit(client, p), journal = memoryJournal(); + await fs.writeFile(path.join(root, 'moved.txt'), 'someone else'); + await assert.rejects(applyWorkspaceEdit(client, preview, {journal, reviewed: reviewed(preview), authorize: () => true}), {code: 'conflict'}); + assert.equal((await client.readFile(first.path)).content, first.content); + const blocked = await previewWorkspaceEdit(client, p); assert.equal(blocked.ready, false); assert.equal(blocked.conflicts[0].code, 'destination_exists'); + await fs.unlink(path.join(root, 'moved.txt')); await fs.writeFile(path.join(root, second.path), 'external change'); + await assert.rejects(applyWorkspaceEdit(client, preview, {journal, reviewed: reviewed(preview), authorize: () => true}), {code: 'conflict'}); + assert.equal((await client.readFile(first.path)).content, first.content); +}); + +test('revocation, journal failure and a concurrent external edit stop a partially applied plan', async t => { + const {client, root} = await fixture(t), first = await client.readFile('first.txt'), second = await client.readFile('second.txt'); + const p = proposal([{kind: 'edit', path: first.path, baseRevision: first.revision, edits: [edit(0, 5, 'beta')]}, {kind: 'edit', path: second.path, baseRevision: second.revision, edits: [edit(0, 5, 'beta')]}]); + const preview = await previewWorkspaceEdit(client, p), journal = memoryJournal(); + const receipt = await applyWorkspaceEdit(client, preview, {journal, reviewed: reviewed(preview), authorize: ({index}) => index !== 1}); + assert.equal(receipt.status, 'partial'); assert.equal(receipt.errorCode, 'permission_denied'); + assert.equal((await client.readFile(second.path)).content, second.content); + const next = await previewWorkspaceEdit(client, await editFirst(client, 'gamma')); + const broken = memoryJournal(); broken.write = async () => {throw new Error('journal disk failure');}; + const failed = await applyWorkspaceEdit(client, next, {journal: broken, reviewed: reviewed(next), authorize: () => true}); + assert.equal(failed.status, 'not-applied'); assert.equal(failed.journalError, 'provider_failed'); + assert.equal((await client.readFile(first.path)).content, 'beta 😀\r\nnext'); + const concurrent = await previewWorkspaceEdit(client, await editFirst(client, 'gamma')); + const changed = await applyWorkspaceEdit(client, concurrent, {journal: memoryJournal(), reviewed: reviewed(concurrent), authorize: async ({phase}) => {if (phase === 'apply') await fs.writeFile(path.join(root, first.path), 'other editor'); return true;}}); + assert.equal(changed.executionStopped, true); assert.equal(changed.steps[0].state, 'conflicted'); + assert.equal((await client.readFile(first.path)).content, 'other editor'); +}); + +test('a lost response is read back without replay and a failed receipt write remains recoverable', async t => { + const {client} = await fixture(t), journal = memoryJournal(), preview = await previewWorkspaceEdit(client, await editFirst(client)); + let calls = 0; + const lost = {...client, writeFile: async (...args) => {calls++; await client.writeFile(...args); throw Object.assign(new Error(), {code: 'transport_failed'});}}; + const receipt = await applyWorkspaceEdit(lost, preview, {journal, reviewed: reviewed(preview), authorize: () => true}); + assert.equal(receipt.status, 'completed'); assert.equal(receipt.executionStopped, true); assert.equal(calls, 1); + assert.equal((await recoverWorkspaceEdit(client, journal, preview.planId)).status, 'completed'); + const second = await previewWorkspaceEdit(client, await editFirst(client, 'delta')); + const broken = memoryJournal(), save = broken.write; + broken.write = async record => {if (record.steps[0].state === 'applied') throw new Error('lost receipt'); await save(record);}; + const applied = await applyWorkspaceEdit(client, second, {journal: broken, reviewed: reviewed(second), authorize: () => true}); + assert.equal(applied.status, 'completed'); assert.equal(applied.journalError, 'provider_failed'); + assert.equal((await broken.read(second.planId)).steps[0].state, 'intent'); + assert.equal((await recoverWorkspaceEdit(client, broken, second.planId)).status, 'completed'); +}); + +test('read-only hosts, changed generation and cancellation cannot apply an old preview', async t => { + const {client} = await fixture(t, {writable: false}), preview = await previewWorkspaceEdit(client, await editFirst(client)); + const opts = {journal: memoryJournal(), reviewed: reviewed(preview), authorize: () => true}; + await assert.rejects(applyWorkspaceEdit(client, preview, opts), {code: 'permission_denied'}); + const changed = {...client, binding: {...client.binding, workspace: {...client.binding.workspace, generation: randomUUID()}}}; + await assert.rejects(applyWorkspaceEdit(changed, preview, opts), {code: 'generation_mismatch'}); + await assert.rejects(applyWorkspaceEdit(client, preview, {...opts, signal: AbortSignal.abort()}), {code: 'cancelled'}); +}); + +test('Node journal refuses workspace nesting, wrong identities, live writers and filesystem links', async t => { + const {parent, root, workspaceId, client, openJournal} = await fixture(t); + await assert.rejects(openJournal({directory: path.join(root, 'journal')}), {code: 'unsafe_path'}); + const journal = await openJournal(); await assert.rejects(openJournal({recoverStaleLock: true}), {code: 'conflict'}); + const preview = await previewWorkspaceEdit(client, await editFirst(client)); + await applyWorkspaceEdit(client, preview, {journal, reviewed: reviewed(preview), authorize: () => true}); + const recordFile = path.join(parent, 'journal', preview.planId, 'record.json'); + await fs.link(recordFile, path.join(parent, 'alias.json')); + await assert.rejects(journal.read(preview.planId), {code: 'unsafe_path'}); await fs.unlink(path.join(parent, 'alias.json')); + await journal.close(); await assert.rejects(openJournal({workspaceId: randomUUID()}), {code: 'workspace_mismatch'}); + const external = path.join(parent, 'external'); await fs.mkdir(external); await fs.writeFile(path.join(external, 'foreign.txt'), 'preserve'); + await fs.symlink(external, path.join(root, 'linked'), process.platform === 'win32' ? 'junction' : 'dir'); + await assert.rejects(previewWorkspaceEdit(client, proposal([{kind: 'delete', path: 'linked/foreign.txt', baseRevision: editHash('preserve')}])), {code: 'unsafe_path'}); + assert.equal(await fs.readFile(path.join(external, 'foreign.txt'), 'utf8'), 'preserve'); + assert.equal(typeof workspaceId, 'string'); +}); + +test('rename proposals and preparation are language data with validated request positions', async () => { + const snapshot = {uri: 'memory:///demo', languageId: 'teaching', modelVersion: 1, workspaceRevision: 'r1', text: 'alpha 😀'}; + const req = {protocolVersion: 1, requestId: 'rename', scope: {projectId: 'p', sessionId: 's'}, snapshot, position: {line: 0, character: 2}, kind: 'rename', newName: 'beta'}; + assert.equal(parseLanguageRequest(req).newName, 'beta'); + for (const newName of ['', '\ud800', 'bad\nname', 'x'.repeat(257)]) assert.throws(() => parseLanguageRequest({...req, newName}), {code: 'invalid_contract'}); + const {newName: _, ...prepare} = {...req, kind: 'prepare-rename'}; + assert.throws(() => createLanguageResult(prepare, {placeholder: 'other', range: edit(3, 5, '').range}), {code: 'invalid_contract'}); + assert.throws(() => createLanguageResult(prepare, {placeholder: 'split', range: edit(6, 7, '').range}), {code: 'invalid_contract'}); + assert.throws(() => createLanguageResult(req, proposal([{kind: 'delete', path: '.env', baseRevision: 'a'.repeat(64)}])), {code: 'invalid_contract'}); + const registry = createLanguageRegistry('rename'), finished = deferred(), started = deferred(); + registry.register('slow', {languages: ['teaching']}, {provide: async request => {started.resolve(); await finished.promise; return createLanguageResult(request, null);}}); + const pending = registry.request(req), rejected = assert.rejects(pending, {code: 'stale_snapshot'}); + await started.promise; registry.invalidate(); await rejected; finished.resolve(); registry.dispose(); +}); + +test('a stalled approval can be cancelled; corrupted journals and simultaneous apply are rejected before writes', async t => { + const {client} = await fixture(t), preview = await previewWorkspaceEdit(client, await editFirst(client)), journal = memoryJournal(); + const started = deferred(), never = deferred(), controller = new AbortController(); + const operation = applyWorkspaceEdit(client, preview, {journal, reviewed: reviewed(preview), signal: controller.signal, authorize: () => {started.resolve(); return never.promise;}}); + const rejected = assert.rejects(operation, {code: 'cancelled'}); await started.promise; + await assert.rejects(applyWorkspaceEdit(client, preview, {journal, reviewed: reviewed(preview), authorize: () => true}), {code: 'conflict'}); + await assert.rejects(recoverWorkspaceEdit(client, journal, preview.planId), {code: 'conflict'}); + controller.abort(); await rejected; never.resolve(true); + const corrupted = memoryJournal(), originalRead = corrupted.read; + corrupted.read = async id => ({...await originalRead(id), revision: 999}); + await assert.rejects(applyWorkspaceEdit(client, preview, {journal: corrupted, reviewed: reviewed(preview), authorize: () => true}), {code: 'conflict'}); + assert.equal((await client.readFile('first.txt')).content, 'alpha 😀\r\nnext'); +}); + +test('recovery after a host restart uses current reads while the old preview cannot be applied', async t => { + const {client, server, root, workspaceId, openJournal} = await fixture(t), journal = await openJournal(); + const preview = await previewWorkspaceEdit(client, await editFirst(client)); + assert.equal((await applyWorkspaceEdit(client, preview, {journal, reviewed: reviewed(preview), authorize: () => true})).status, 'completed'); + await server.close(); + const replacement = await createWorkspaceServer({root, workspaceId, token, notice: {id: 'restart', version: '1', text: 'Fresh connection'}}), current = createWorkspaceClient({url: replacement.url, token}); + try { + await current.connect(); assert.notEqual(current.binding.workspace.generation, preview.workspace.generation); + await assert.rejects(applyWorkspaceEdit(current, preview, {journal, reviewed: reviewed(preview), authorize: () => true}), {code: 'generation_mismatch'}); + const recovered = await recoverWorkspaceEdit(current, journal, preview.planId); + assert.equal(recovered.status, 'completed'); assert.equal(recovered.currentGeneration, current.binding.workspace.generation); + } finally {current.dispose(); await replacement.close();} +}); From b95cf780b14e42e5b9da7061b12e95aa782e6695 Mon Sep 17 00:00:00 2001 From: hkimw <54717101+hkimw@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:49:45 +0900 Subject: [PATCH 3/4] feat: add formatting and diagnostic-bound code action proposals --- CHANGELOG.md | 4 + PUBLIC_SURFACE.json | 8 + README.md | 2 + docs/COMPATIBILITY.md | 3 + docs/LANGUAGE_EDITING.md | 126 +++ examples/language-editing/LICENSE | 202 ++++ examples/language-editing/plugin.mjs | 46 + examples/language-editing/run.mjs | 53 + package.json | 3 +- schemas/code-action.schema.json | 341 +++++++ schemas/formatting-options.schema.json | 37 + schemas/language-capabilities.schema.json | 9 +- schemas/language-request.schema.json | 579 +++++++++++ schemas/language-result.schema.json | 1074 +++++++++++++++++++++ schemas/manifest.schema.json | 28 +- scripts/pack-check.mjs | 3 + src/contracts.mjs | 33 +- src/host.mjs | 47 +- src/index.d.mts | 46 +- src/index.mjs | 1 + src/language-assistance-schemas.mjs | 2 +- src/language-editing-schemas.mjs | 14 + src/language-editing.mjs | 80 ++ src/language-registry.mjs | 50 +- src/schemas.mjs | 25 +- tests/language-editing.test.mjs | 143 +++ tests/types/language-editing.mts | 27 + 27 files changed, 2939 insertions(+), 47 deletions(-) create mode 100644 docs/LANGUAGE_EDITING.md create mode 100644 examples/language-editing/LICENSE create mode 100644 examples/language-editing/plugin.mjs create mode 100644 examples/language-editing/run.mjs create mode 100644 schemas/code-action.schema.json create mode 100644 schemas/formatting-options.schema.json create mode 100644 src/language-editing-schemas.mjs create mode 100644 src/language-editing.mjs create mode 100644 tests/language-editing.test.mjs create mode 100644 tests/types/language-editing.mts diff --git a/CHANGELOG.md b/CHANGELOG.md index 615064d..f354bd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.11.0 — Unreleased +- Add document/range formatting and diagnostic-bound code action lists/resolution. + Validate selection scope, newline policy and current snapshots; route proposed + changes through separately reviewed workspace edits. Preserve diagnostic APIs. + - Add prepare-rename/rename providers, reviewed multi-file edit plans, reproducible previews, per-file CAS, durable intent/receipt journals and observational recovery. Add independently reviewed inverse proposals and killed-process HTTP examples. diff --git a/PUBLIC_SURFACE.json b/PUBLIC_SURFACE.json index 82befc3..1a28728 100644 --- a/PUBLIC_SURFACE.json +++ b/PUBLIC_SURFACE.json @@ -20,6 +20,7 @@ "docs/JOBS.md", "docs/LANGUAGE.md", "docs/LANGUAGE_ASSISTANCE.md", + "docs/LANGUAGE_EDITING.md", "docs/LICENSING.md", "docs/OBSERVATION.md", "docs/performance.md", @@ -65,6 +66,9 @@ "examples/language-assistance/LICENSE", "examples/language-assistance/plugin.mjs", "examples/language-assistance/run.mjs", + "examples/language-editing/LICENSE", + "examples/language-editing/plugin.mjs", + "examples/language-editing/run.mjs", "examples/project-query/run.mjs", "examples/project-session/run.mjs", "examples/project-tools/browser.html", @@ -119,6 +123,8 @@ "schemas/browser-artifact-checkpoint.schema.json", "schemas/command.schema.json", "schemas/completion-item.schema.json", + "schemas/code-action.schema.json", + "schemas/formatting-options.schema.json", "schemas/diagnostics-request.schema.json", "schemas/diagnostics-result.schema.json", "schemas/error.schema.json", @@ -211,6 +217,8 @@ "src/jobs.mjs", "src/language-assistance-schemas.mjs", "src/language-assistance.mjs", + "src/language-editing.mjs", + "src/language-editing-schemas.mjs", "src/language-registry.mjs", "src/language-values.mjs", "src/lifecycle.mjs", diff --git a/README.md b/README.md index db3b0f3..77b0813 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ including lazy item resolution, limited snippets and pure insertion previews. Run `npm run example:assistance` for the nested-call and Unicode example. [Reviewed workspace edits](docs/WORKSPACE_EDITS.md) add rename proposals, multi-file previews, conditional application and readback after interrupted operations. +[Formatting and code actions](docs/LANGUAGE_EDITING.md) use that review path for +document/range formatting and diagnostic-bound, optionally lazy quick fixes. The 0.10 line adds [browser artifact streaming](docs/BROWSER_ARTIFACTS.md) with bounded sinks, incremental whole-file hashes, explicit storage readback and diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index c0a74ed..7c3b0b7 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -11,6 +11,9 @@ See [language assistance](LANGUAGE_ASSISTANCE.md) for bounds and runtime checks. `prepare-rename`/`rename` add proposals; [workspace edits](WORKSPACE_EDITS.md) preview and apply them through existing file methods with a separate current approval and journal. The Node journal is optional, operator-owned storage outside the workspace. +[Formatting and code actions](LANGUAGE_EDITING.md) are opt-in language proposals; +accepted diagnostic publications invalidate action selections. Existing diagnostic +call results and single-file edit sessions retain their contracts. | Surface | Supported scope | | --- | --- | diff --git a/docs/LANGUAGE_EDITING.md b/docs/LANGUAGE_EDITING.md new file mode 100644 index 0000000..6a227a6 --- /dev/null +++ b/docs/LANGUAGE_EDITING.md @@ -0,0 +1,126 @@ +# Formatting and diagnostic code actions + +SDK 0.11 adds `format-document`, `format-range`, `code-actions` and optional +`code-action-resolve` to the trusted in-process language host. Declare each used +feature in manifest v2 with `document.read` and `language.provide` grants. A resolver +also requires `code-actions` and an actual provider `resolve` method. These grants +only permit proposals; they do not grant workspace mutation. + +Run `npm run example:language-editing`, or copy `examples/language-editing` from the +installed package and run `run.mjs`. The example trims selected/document whitespace, +preserves CRLF and Unicode, previews a diagnostic quick-fix, detects an external +edit, and applies only the newly reviewed selected fix through a real HTTP client +and persistent journal. Another TODO and the external edit remain unchanged. + +## Format requests and results + +```js +const formatted = await host.requestLanguage('format-range', { + path: 'main.dds', + range: {start: {line: 0, character: 0}, end: {line: 5, character: 0}}, + formatOptions: {tabSize: 2, insertSpaces: true, trimTrailingWhitespace: true}, +}); +const proposedEdit = host.prepareFormatting(formatted); +``` + +Use `format-document` without `range` for the whole document. Both require an +explicit workspace-relative path and `formatOptions`; neither takes `position`. +Options require `tabSize` (1..16) and boolean `insertSpaces`. Optional booleans are +`trimTrailingWhitespace`, `insertFinalNewline` and `trimFinalNewlines`. The language +provider interprets formatting policy and may return no proposal. + +Optional `endOfLine` is `preserve` (default), `lf` or `crlf`. Inserted/replacement +newlines must use the selected style. Preserve allows the newline styles already +present in the source; it uses LF when there are none. Unedited text keeps its +original bytes, including when a range formatter uses an explicit different style. + +A provider returns `null` or a `WorkspaceEdit` containing exactly one `edit` for the +requested path, with the SHA-256 of the current snapshot text as `baseRevision`. +Every UTF-16 edit must be valid and non-overlapping. Range formatting additionally +requires every edit, including insertions, to stay within the selection. Edits cannot +split a surrogate or CRLF, use a different file/base or exceed the final file budget. +The host normalizes content-identical proposals to `null`; return `null` when there +are no edits. Empty change lists are invalid. `prepareFormatting` accepts the actual +host result object and rejects stale or foreign results. It performs no write. + +## Diagnostic identity and actions + +```js +await host.requestDiagnostics(); +const actions = await host.requestLanguage('code-actions', { + path: 'main.dds', range: selectedRange, + context: {triggerKind: 'invoked', only: ['quickfix']}, +}); +const selected = actions.data[0]; +const resolved = selected.resolveToken + ? await host.resolveCodeAction(selected.resolveToken) : actions; +const proposedEdit = host.prepareCodeAction(resolved, 0); +// Preview proposedEdit with the separately connected workspace client. +host.releaseCodeActions(actions); +host.releaseCodeActions(resolved); +``` + +Only proceed when an action exists and is enabled. `only` is optional and accepts +the finite exported `CODE_ACTION_KINDS`. `refactor` also matches its declared +sub-kinds. Context defaults to `{triggerKind: 'invoked'}`; the alternative is +`automatic`. Requests require a selected `range` and explicit `path`. + +The host supplies a request-only `diagnosticContext` with a monotonic `revision` +and the currently accepted diagnostic list for that document. Caller input cannot +forge this bundle. Before the first accepted diagnostic publication it is empty. +Accepted diagnostic results invalidate all retained action selections and resolve +tokens, even if their text is unchanged. Provider registration/disposal and document +replacement clear the bundle. Out-of-order diagnostics may still return to their +original caller, preserving that API, but never replace a newer accepted action +context. An unsuccessful diagnostic request does not publish a new bundle. + +Each action has a plain-text `title`, a `kind`, optional `isPreferred`, optional +`diagnosticIndices` into that exact bundle, and an `edit`, private `resolveData`, +or a `disabled: {reason}` value. Disabled actions cannot carry edits/resolvers. +Kinds are `quickfix`, `refactor`, `refactor.extract`, `refactor.inline`, +`refactor.rewrite`, `source.organizeImports` and `source.fixAll`. + +An action can propose other files through workspace-relative `WorkspaceEdit` +changes; those files require authorized preview reads and current CAS before apply. +Edits to the current request path must already match the snapshot hash and valid +text geometry. Action edits may extend beyond the selected range (for example a +file-wide fix or refactor); the complete plan must be shown during review. + +## Lazy resolution, bounds and apply + +The host replaces `resolveData` with a one-use opaque token bound to the provider, +document, request path/range, diagnostic revision/bundle and selected item. The +resolver receives the original request and item. It may supply an edit or disable +the action, but cannot change title, kind, preferred status or diagnostic references. +Commands, shell arguments, URLs and unknown executable fields are rejected. + +Limits are 100 actions, 256 UTF-16 units per title, 2,048 per disabled reason and +500 distinct diagnostic indices. Shared resolver limits are 4 KiB private data, +128 retained tokens, 4 MiB charged request/item data, 60-second expiry and four +actual resolver operations per feature. Each feature still has at most 64 actual +pending operations. Cancelled providers that ignore their signal retain their +slot until settlement; late results are discarded. Consumed or expired tokens +require a new request, not automatic replay. Release unneeded lists explicitly. + +`prepareCodeAction` checks that the actual result still belongs to the active host +and provider and that the document and diagnostic revision remain current. It +rejects disabled or unresolved selections. It returns only a proposal. Use +[`previewWorkspaceEdit` and reviewed apply](WORKSPACE_EDITS.md) for current file +versions, user review, authorization, durable intent and partial-failure receipts. +Diagnostic/formatting capabilities never act as approval for that separate step. + +All returned strings are display text. Render even HTML-looking titles/reasons as +text. No new workspace HTTP method, command execution mechanism or DDS product +editor support is established by these SDK APIs. Existing diagnostics, literal +completions and single-file `saveEdits` remain supported. + +## 한국어 + +문서/범위 포맷팅과 코드 액션은 수정안만 반환합니다. 포맷팅은 현재 파일·내용 해시와 +UTF-16 범위를 확인하며, 선택 범위 밖 수정과 겹치는 삽입을 거부합니다. 변경이 없으면 +`null`을 반환하고 기존 CRLF는 기본 정책에 따라 보존합니다. + +코드 액션에는 호스트가 관리하는 최신 진단 묶음과 revision이 연결됩니다. 진단 갱신, +문서 변경, 제공자 해제 뒤에는 이전 선택과 지연 해석 토큰을 사용할 수 없습니다. +선택한 수정안도 별도의 파일 미리보기·정확한 계획 해시 검토·현재 권한·CAS를 거쳐야 +적용됩니다. 표시 문자열을 HTML이나 명령으로 실행하지 않습니다. diff --git a/examples/language-editing/LICENSE b/examples/language-editing/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/examples/language-editing/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/examples/language-editing/plugin.mjs b/examples/language-editing/plugin.mjs new file mode 100644 index 0000000..6268db1 --- /dev/null +++ b/examples/language-editing/plugin.mjs @@ -0,0 +1,46 @@ +import {definePlugin, createDiagnosticsResult, createLanguageResult} from '@altifigence/dds-plugin-sdk'; + +const compare = (a, b) => a.line - b.line || a.character - b.character; +async function editPlan(request, edits, title) { + const bytes = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(request.snapshot.text))); + const baseRevision = [...bytes].map(byte => byte.toString(16).padStart(2, '0')).join(''); + return {formatVersion: 1, id: crypto.randomUUID(), title, changes: [{kind: 'edit', path: request.path, baseRevision, edits}]}; +} +export default definePlugin({ + manifestVersion: 2, id: 'teaching-editing', name: 'Teaching Editing', publisher: 'example', version: '1.0.0', + protocolVersion: 1, entry: './plugin.mjs', runtime: 'ui', supportedHosts: ['test-host'], license: 'Apache-2.0', + source: {visibility: 'open', licenseFile: 'LICENSE'}, + capabilities: ['diagnostics', 'format-document', 'format-range', 'code-actions', 'code-action-resolve'], + permissions: ['document.read', 'diagnostics.publish', 'language.provide'], +}, context => { + context.registerDiagnosticsProvider({languages: ['teaching']}, {provideDiagnostics(request) { + const diagnostics = []; + request.snapshot.text.split(/\r\n|\r|\n/).forEach((line, number) => { + for (const match of line.matchAll(/\bTODO\b/g)) diagnostics.push({range: {start: {line: number, character: match.index}, end: {line: number, character: match.index + 4}}, severity: 'info', code: 'todo', source: 'teaching', message: 'Replace this TODO when the work is finished.'}); + }); + return createDiagnosticsResult(request, diagnostics); + }}); + const formatter = {async provide(request, {signal}) { + signal.throwIfAborted(); const edits = []; + if (request.formatOptions.trimTrailingWhitespace) request.snapshot.text.split(/\r\n|\r|\n/).forEach((line, number) => { + const trailing = line.match(/[ \t]+$/); if (!trailing) return; + const range = {start: {line: number, character: trailing.index}, end: {line: number, character: line.length}}; + if (request.range && (compare(range.start, request.range.start) < 0 || compare(range.end, request.range.end) > 0)) return; + edits.push({range, text: ''}); + }); + return createLanguageResult(request, edits.length ? await editPlan(request, edits, 'Trim trailing whitespace') : null); + }}; + context.registerLanguageProvider('format-document', {languages: ['teaching']}, formatter); + context.registerLanguageProvider('format-range', {languages: ['teaching']}, formatter); + context.registerLanguageProvider('code-actions', {languages: ['teaching']}, { + provide(request, {signal}) { + signal.throwIfAborted(); + if (request.context.only && !request.context.only.includes('quickfix')) return createLanguageResult(request, []); + return createLanguageResult(request, request.diagnosticContext.diagnostics.map((diagnostic, index) => ({title: 'Replace TODO with DONE', kind: 'quickfix', diagnosticIndices: [index], isPreferred: index === 0, resolveData: {index}}))); + }, + async resolve(request, item, {signal}) { + signal.throwIfAborted(); const diagnostic = request.diagnosticContext.diagnostics[item.resolveData.index]; + return {...item, edit: await editPlan(request, [{range: diagnostic.range, text: 'DONE'}], 'Apply the selected TODO fix')}; + }, + }); +}); diff --git a/examples/language-editing/run.mjs b/examples/language-editing/run.mjs new file mode 100644 index 0000000..ac6d749 --- /dev/null +++ b/examples/language-editing/run.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import * as fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import {randomUUID} from 'node:crypto'; +import {createPluginHost} from '@altifigence/dds-plugin-sdk'; +import {createWorkspaceServer} from '@altifigence/dds-plugin-sdk/workspace-node'; +import {createWorkspaceClient} from '@altifigence/dds-plugin-sdk/workspace-client'; +import {previewWorkspaceEdit, applyWorkspaceEdit} from '@altifigence/dds-plugin-sdk/workspace-edits'; +import {createNodeWorkspaceEditJournal} from '@altifigence/dds-plugin-sdk/workspace-edits-node'; +import plugin from './plugin.mjs'; + +const parent = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'dds-format-example-'))), root = path.join(parent, 'workspace'), workspaceId = randomUUID(); +const token = 'disposable-formatting-example-token-0123456789'; let server, client, host, journal, version = 0; +const formatOptions = {tabSize: 2, insertSpaces: true, trimTrailingWhitespace: true}; +try { + await fs.mkdir(root); await fs.writeFile(path.join(root, 'main.dds'), 'TODO \r\nTODO 😀 \r\n'); + server = await createWorkspaceServer({root, workspaceId, token, notice: {id: 'format-example', version: '1', text: 'Disposable formatting example'}}); + client = createWorkspaceClient({url: server.url, token}); await client.connect(); + journal = await createNodeWorkspaceEditJournal({directory: path.join(parent, 'journal'), workspaceRoot: root, workspaceId}); + host = createPluginHost({grants: ['document.read', 'diagnostics.publish', 'language.provide']}); await host.activate(plugin); + async function refresh() { + const file = await client.readFile('main.dds'); + host.setDocument({uri: 'memory:///main.dds', languageId: 'teaching', modelVersion: ++version, workspaceRevision: file.revision, text: file.content}); + await host.requestDiagnostics(); return file; + } + const original = await refresh(); + const rangeResult = await host.requestLanguage('format-range', {path: 'main.dds', range: {start: {line: 0, character: 0}, end: {line: 0, character: 6}}, formatOptions}); + assert.equal(host.prepareFormatting(rangeResult).changes[0].edits.length, 1); + const formatted = await host.requestLanguage('format-document', {path: 'main.dds', formatOptions}); + const preview = await previewWorkspaceEdit(client, host.prepareFormatting(formatted)); + assert.equal((await client.readFile('main.dds')).content, original.content); + const approve = preview => ({journal, reviewed: {planId: preview.planId, digest: preview.digest}, authorize: () => true}); + assert.equal((await applyWorkspaceEdit(client, preview, approve(preview))).status, 'completed'); + assert.equal((await refresh()).content, 'TODO\r\nTODO 😀\r\n'); + assert.equal((await host.requestLanguage('format-document', {path: 'main.dds', formatOptions})).data, null); + const actionInput = {path: 'main.dds', range: {start: {line: 0, character: 0}, end: {line: 2, character: 0}}, context: {triggerKind: 'invoked', only: ['quickfix']}}; + const listed = await host.requestLanguage('code-actions', actionInput); assert.equal(listed.data.length, 2); + const selected = await host.resolveCodeAction(listed.data[0].resolveToken); + const stalePreview = await previewWorkspaceEdit(client, host.prepareCodeAction(selected)); + await fs.appendFile(path.join(root, 'main.dds'), '// keep outside edit\r\n'); + await assert.rejects(applyWorkspaceEdit(client, stalePreview, approve(stalePreview)), {code: 'conflict'}); + await refresh(); assert.throws(() => host.prepareCodeAction(selected), {code: 'stale_snapshot'}); + const current = await host.requestLanguage('code-actions', actionInput); + const resolved = await host.resolveCodeAction(current.data[0].resolveToken); + const currentPreview = await previewWorkspaceEdit(client, host.prepareCodeAction(resolved)); + assert.equal((await applyWorkspaceEdit(client, currentPreview, approve(currentPreview))).status, 'completed'); + assert.equal((await client.readFile('main.dds')).content, 'DONE\r\nTODO 😀\r\n// keep outside edit\r\n'); + console.log('range formatting, CRLF preservation, diagnostic quick-fix, conflict and selected approval verified'); +} finally { + host?.dispose(); client?.dispose(); await journal?.close(); await server?.close(); + assert.equal(path.dirname(parent), await fs.realpath(os.tmpdir())); assert.ok(path.basename(parent).startsWith('dds-format-example-')); await fs.rm(parent, {recursive: true}); +} diff --git a/package.json b/package.json index 40e0417..bb7d2cc 100644 --- a/package.json +++ b/package.json @@ -190,7 +190,8 @@ "benchmark:transfer-queue": "node scripts/benchmark-transfer-queue.mjs", "example:browser-resume": "node examples/browser-resume/serve.mjs", "example:assistance": "node examples/language-assistance/run.mjs", - "example:workspace-edits": "node examples/workspace-edits/run.mjs" + "example:workspace-edits": "node examples/workspace-edits/run.mjs", + "example:language-editing": "node examples/language-editing/run.mjs" }, "devDependencies": { "typescript": "5.9.3" diff --git a/schemas/code-action.schema.json b/schemas/code-action.schema.json new file mode 100644 index 0000000..87ad84f --- /dev/null +++ b/schemas/code-action.schema.json @@ -0,0 +1,341 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/code-action.schema.json", + "title": "code-action", + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "kind": { + "enum": [ + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source.organizeImports", + "source.fixAll" + ] + }, + "isPreferred": { + "type": "boolean" + }, + "disabled": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "reason" + ], + "additionalProperties": false + }, + "diagnosticIndices": { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 499 + }, + "maxItems": 500, + "uniqueItems": true + }, + "edit": { + "type": "object", + "properties": { + "formatVersion": { + "const": 1 + }, + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "changes": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "const": "edit" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "baseRevision": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$" + }, + "edits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "range": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + }, + "end": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false + }, + "text": { + "type": "string", + "minLength": 0, + "maxLength": 262144, + "x-maxUtf8Bytes": 262144 + } + }, + "required": [ + "range", + "text" + ], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 500 + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "kind", + "path", + "baseRevision", + "edits" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "const": "create" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "content": { + "type": "string", + "minLength": 0, + "maxLength": 262144, + "x-maxUtf8Bytes": 262144 + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "kind", + "path", + "content" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "const": "move" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "baseRevision": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$" + }, + "newPath": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "kind", + "path", + "baseRevision", + "newPath" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "const": "delete" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "baseRevision": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$" + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "kind", + "path", + "baseRevision" + ], + "additionalProperties": false + } + ] + }, + "minItems": 1, + "maxItems": 32 + } + }, + "required": [ + "formatVersion", + "id", + "title", + "changes" + ], + "additionalProperties": false, + "x-maxUtf8Bytes": 1048576, + "$comment": "At most 1000 edits in all files. Runtime validates paths, scalar strings and edit geometry. Revisions are full content SHA-256. Destinations must be absent, and affected paths cannot be duplicated or nested." + }, + "resolveData": { + "x-maxUtf8Bytes": 4096 + }, + "resolveToken": { + "type": "string", + "minLength": 1, + "maxLength": 36, + "pattern": "^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$" + } + }, + "required": [ + "title", + "kind" + ], + "additionalProperties": false, + "allOf": [ + { + "if": { + "required": [ + "disabled" + ] + }, + "then": { + "properties": { + "edit": false, + "resolveData": false, + "resolveToken": false + } + }, + "else": { + "anyOf": [ + { + "required": [ + "edit" + ] + }, + { + "required": [ + "resolveData" + ] + }, + { + "required": [ + "resolveToken" + ] + } + ] + } + } + ], + "$comment": "Runtime validates bounded JSON, diagnostics against the current bundle, action kinds against requested filters, and current-document edits. Host tokens cannot be supplied by providers. Labels, disabled reasons and other display text are never executable." +} diff --git a/schemas/formatting-options.schema.json b/schemas/formatting-options.schema.json new file mode 100644 index 0000000..85426a2 --- /dev/null +++ b/schemas/formatting-options.schema.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/formatting-options.schema.json", + "title": "formatting-options", + "type": "object", + "properties": { + "tabSize": { + "type": "integer", + "minimum": 1, + "maximum": 16 + }, + "insertSpaces": { + "type": "boolean" + }, + "trimTrailingWhitespace": { + "type": "boolean" + }, + "insertFinalNewline": { + "type": "boolean" + }, + "trimFinalNewlines": { + "type": "boolean" + }, + "endOfLine": { + "enum": [ + "preserve", + "lf", + "crlf" + ] + } + }, + "required": [ + "tabSize", + "insertSpaces" + ], + "additionalProperties": false +} diff --git a/schemas/language-capabilities.schema.json b/schemas/language-capabilities.schema.json index 15fa063..dd0c041 100644 --- a/schemas/language-capabilities.schema.json +++ b/schemas/language-capabilities.schema.json @@ -16,12 +16,18 @@ "document-symbols", "signature-help", "prepare-rename", - "rename" + "rename", + "format-document", + "format-range", + "code-actions" ] }, "completionResolve": { "const": true }, + "codeActionResolve": { + "const": true + }, "snippets": { "const": true }, @@ -89,6 +95,7 @@ "protocolVersion", "features", "completionResolve", + "codeActionResolve", "snippets", "positions", "limits" diff --git a/schemas/language-request.schema.json b/schemas/language-request.schema.json index 867ba8b..b53624c 100644 --- a/schemas/language-request.schema.json +++ b/schemas/language-request.schema.json @@ -964,6 +964,585 @@ "newName" ], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "protocolVersion": { + "const": 1 + }, + "requestId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "scope": { + "type": "object", + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "sessionId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "projectId", + "sessionId" + ], + "additionalProperties": false + }, + "snapshot": { + "type": "object", + "properties": { + "uri": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "pattern": "^[A-Za-z][A-Za-z0-9+.-]*:[^\\s\\u0000-\\u001f\\u007f]+$" + }, + "languageId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$" + }, + "modelVersion": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "workspaceRevision": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "text": { + "type": "string", + "maxLength": 262144, + "x-maxUtf8Bytes": 262144 + } + }, + "required": [ + "uri", + "languageId", + "modelVersion", + "workspaceRevision", + "text" + ], + "additionalProperties": false + }, + "kind": { + "const": "format-document" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "formatOptions": { + "type": "object", + "properties": { + "tabSize": { + "type": "integer", + "minimum": 1, + "maximum": 16 + }, + "insertSpaces": { + "type": "boolean" + }, + "trimTrailingWhitespace": { + "type": "boolean" + }, + "insertFinalNewline": { + "type": "boolean" + }, + "trimFinalNewlines": { + "type": "boolean" + }, + "endOfLine": { + "enum": [ + "preserve", + "lf", + "crlf" + ] + } + }, + "required": [ + "tabSize", + "insertSpaces" + ], + "additionalProperties": false + } + }, + "required": [ + "protocolVersion", + "requestId", + "scope", + "snapshot", + "kind", + "path", + "formatOptions" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "protocolVersion": { + "const": 1 + }, + "requestId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "scope": { + "type": "object", + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "sessionId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "projectId", + "sessionId" + ], + "additionalProperties": false + }, + "snapshot": { + "type": "object", + "properties": { + "uri": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "pattern": "^[A-Za-z][A-Za-z0-9+.-]*:[^\\s\\u0000-\\u001f\\u007f]+$" + }, + "languageId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$" + }, + "modelVersion": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "workspaceRevision": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "text": { + "type": "string", + "maxLength": 262144, + "x-maxUtf8Bytes": 262144 + } + }, + "required": [ + "uri", + "languageId", + "modelVersion", + "workspaceRevision", + "text" + ], + "additionalProperties": false + }, + "kind": { + "const": "format-range" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "range": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + }, + "end": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false + }, + "formatOptions": { + "type": "object", + "properties": { + "tabSize": { + "type": "integer", + "minimum": 1, + "maximum": 16 + }, + "insertSpaces": { + "type": "boolean" + }, + "trimTrailingWhitespace": { + "type": "boolean" + }, + "insertFinalNewline": { + "type": "boolean" + }, + "trimFinalNewlines": { + "type": "boolean" + }, + "endOfLine": { + "enum": [ + "preserve", + "lf", + "crlf" + ] + } + }, + "required": [ + "tabSize", + "insertSpaces" + ], + "additionalProperties": false + } + }, + "required": [ + "protocolVersion", + "requestId", + "scope", + "snapshot", + "kind", + "path", + "range", + "formatOptions" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "protocolVersion": { + "const": 1 + }, + "requestId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "scope": { + "type": "object", + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "sessionId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "projectId", + "sessionId" + ], + "additionalProperties": false + }, + "snapshot": { + "type": "object", + "properties": { + "uri": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "pattern": "^[A-Za-z][A-Za-z0-9+.-]*:[^\\s\\u0000-\\u001f\\u007f]+$" + }, + "languageId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$" + }, + "modelVersion": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "workspaceRevision": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "text": { + "type": "string", + "maxLength": 262144, + "x-maxUtf8Bytes": 262144 + } + }, + "required": [ + "uri", + "languageId", + "modelVersion", + "workspaceRevision", + "text" + ], + "additionalProperties": false + }, + "kind": { + "const": "code-actions" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "range": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + }, + "end": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false + }, + "context": { + "type": "object", + "properties": { + "triggerKind": { + "enum": [ + "invoked", + "automatic" + ] + }, + "only": { + "type": "array", + "items": { + "enum": [ + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source.organizeImports", + "source.fixAll" + ] + }, + "maxItems": 7, + "minItems": 1, + "uniqueItems": true + } + }, + "required": [ + "triggerKind" + ], + "additionalProperties": false + }, + "diagnosticContext": { + "type": "object", + "properties": { + "revision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "diagnostics": { + "type": "array", + "items": { + "type": "object", + "properties": { + "range": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + }, + "end": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false + }, + "severity": { + "enum": [ + "error", + "warning", + "info", + "hint" + ] + }, + "message": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "code": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "source": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "range", + "severity", + "message" + ], + "additionalProperties": false + }, + "minItems": 0, + "maxItems": 500 + } + }, + "required": [ + "revision", + "diagnostics" + ], + "additionalProperties": false + } + }, + "required": [ + "protocolVersion", + "requestId", + "scope", + "snapshot", + "kind", + "path", + "range", + "diagnosticContext" + ], + "additionalProperties": false } ] } diff --git a/schemas/language-result.schema.json b/schemas/language-result.schema.json index e162c24..38e3e99 100644 --- a/schemas/language-result.schema.json +++ b/schemas/language-result.schema.json @@ -1570,6 +1570,1080 @@ "data" ], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "protocolVersion": { + "const": 1 + }, + "requestId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "scope": { + "type": "object", + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "sessionId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "projectId", + "sessionId" + ], + "additionalProperties": false + }, + "snapshot": { + "type": "object", + "properties": { + "uri": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "pattern": "^[A-Za-z][A-Za-z0-9+.-]*:[^\\s\\u0000-\\u001f\\u007f]+$" + }, + "languageId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$" + }, + "modelVersion": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "workspaceRevision": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "uri", + "languageId", + "modelVersion", + "workspaceRevision" + ], + "additionalProperties": false + }, + "kind": { + "const": "format-document" + }, + "data": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "properties": { + "formatVersion": { + "const": 1 + }, + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "changes": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "const": "edit" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "baseRevision": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$" + }, + "edits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "range": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + }, + "end": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false + }, + "text": { + "type": "string", + "minLength": 0, + "maxLength": 262144, + "x-maxUtf8Bytes": 262144 + } + }, + "required": [ + "range", + "text" + ], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 500 + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "kind", + "path", + "baseRevision", + "edits" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "const": "create" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "content": { + "type": "string", + "minLength": 0, + "maxLength": 262144, + "x-maxUtf8Bytes": 262144 + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "kind", + "path", + "content" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "const": "move" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "baseRevision": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$" + }, + "newPath": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "kind", + "path", + "baseRevision", + "newPath" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "const": "delete" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "baseRevision": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$" + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "kind", + "path", + "baseRevision" + ], + "additionalProperties": false + } + ] + }, + "minItems": 1, + "maxItems": 32 + } + }, + "required": [ + "formatVersion", + "id", + "title", + "changes" + ], + "additionalProperties": false, + "x-maxUtf8Bytes": 1048576, + "$comment": "At most 1000 edits in all files. Runtime validates paths, scalar strings and edit geometry. Revisions are full content SHA-256. Destinations must be absent, and affected paths cannot be duplicated or nested." + } + ] + } + }, + "required": [ + "protocolVersion", + "requestId", + "scope", + "snapshot", + "kind", + "data" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "protocolVersion": { + "const": 1 + }, + "requestId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "scope": { + "type": "object", + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "sessionId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "projectId", + "sessionId" + ], + "additionalProperties": false + }, + "snapshot": { + "type": "object", + "properties": { + "uri": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "pattern": "^[A-Za-z][A-Za-z0-9+.-]*:[^\\s\\u0000-\\u001f\\u007f]+$" + }, + "languageId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$" + }, + "modelVersion": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "workspaceRevision": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "uri", + "languageId", + "modelVersion", + "workspaceRevision" + ], + "additionalProperties": false + }, + "kind": { + "const": "format-range" + }, + "data": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "properties": { + "formatVersion": { + "const": 1 + }, + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "changes": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "const": "edit" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "baseRevision": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$" + }, + "edits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "range": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + }, + "end": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false + }, + "text": { + "type": "string", + "minLength": 0, + "maxLength": 262144, + "x-maxUtf8Bytes": 262144 + } + }, + "required": [ + "range", + "text" + ], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 500 + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "kind", + "path", + "baseRevision", + "edits" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "const": "create" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "content": { + "type": "string", + "minLength": 0, + "maxLength": 262144, + "x-maxUtf8Bytes": 262144 + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "kind", + "path", + "content" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "const": "move" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "baseRevision": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$" + }, + "newPath": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "kind", + "path", + "baseRevision", + "newPath" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "const": "delete" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "baseRevision": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$" + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "kind", + "path", + "baseRevision" + ], + "additionalProperties": false + } + ] + }, + "minItems": 1, + "maxItems": 32 + } + }, + "required": [ + "formatVersion", + "id", + "title", + "changes" + ], + "additionalProperties": false, + "x-maxUtf8Bytes": 1048576, + "$comment": "At most 1000 edits in all files. Runtime validates paths, scalar strings and edit geometry. Revisions are full content SHA-256. Destinations must be absent, and affected paths cannot be duplicated or nested." + } + ] + } + }, + "required": [ + "protocolVersion", + "requestId", + "scope", + "snapshot", + "kind", + "data" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "protocolVersion": { + "const": 1 + }, + "requestId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "scope": { + "type": "object", + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "sessionId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "projectId", + "sessionId" + ], + "additionalProperties": false + }, + "snapshot": { + "type": "object", + "properties": { + "uri": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "pattern": "^[A-Za-z][A-Za-z0-9+.-]*:[^\\s\\u0000-\\u001f\\u007f]+$" + }, + "languageId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$" + }, + "modelVersion": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "workspaceRevision": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "uri", + "languageId", + "modelVersion", + "workspaceRevision" + ], + "additionalProperties": false + }, + "kind": { + "const": "code-actions" + }, + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "kind": { + "enum": [ + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source.organizeImports", + "source.fixAll" + ] + }, + "isPreferred": { + "type": "boolean" + }, + "disabled": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "reason" + ], + "additionalProperties": false + }, + "diagnosticIndices": { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 499 + }, + "maxItems": 500, + "uniqueItems": true + }, + "edit": { + "type": "object", + "properties": { + "formatVersion": { + "const": 1 + }, + "id": { + "type": "string", + "minLength": 36, + "maxLength": 36, + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "changes": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "const": "edit" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "baseRevision": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$" + }, + "edits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "range": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + }, + "end": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false + }, + "text": { + "type": "string", + "minLength": 0, + "maxLength": 262144, + "x-maxUtf8Bytes": 262144 + } + }, + "required": [ + "range", + "text" + ], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 500 + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "kind", + "path", + "baseRevision", + "edits" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "const": "create" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "content": { + "type": "string", + "minLength": 0, + "maxLength": 262144, + "x-maxUtf8Bytes": 262144 + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "kind", + "path", + "content" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "const": "move" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "baseRevision": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$" + }, + "newPath": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "kind", + "path", + "baseRevision", + "newPath" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "const": "delete" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "$comment": "Runtime enforces the shared relative workspace path policy, protected components and cross-platform case-insensitive disjoint paths." + }, + "baseRevision": { + "type": "string", + "minLength": 64, + "maxLength": 64, + "pattern": "^[a-f0-9]{64}$" + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + }, + "required": [ + "kind", + "path", + "baseRevision" + ], + "additionalProperties": false + } + ] + }, + "minItems": 1, + "maxItems": 32 + } + }, + "required": [ + "formatVersion", + "id", + "title", + "changes" + ], + "additionalProperties": false, + "x-maxUtf8Bytes": 1048576, + "$comment": "At most 1000 edits in all files. Runtime validates paths, scalar strings and edit geometry. Revisions are full content SHA-256. Destinations must be absent, and affected paths cannot be duplicated or nested." + }, + "resolveData": { + "x-maxUtf8Bytes": 4096 + }, + "resolveToken": { + "type": "string", + "minLength": 1, + "maxLength": 36, + "pattern": "^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$" + } + }, + "required": [ + "title", + "kind" + ], + "additionalProperties": false, + "allOf": [ + { + "if": { + "required": [ + "disabled" + ] + }, + "then": { + "properties": { + "edit": false, + "resolveData": false, + "resolveToken": false + } + }, + "else": { + "anyOf": [ + { + "required": [ + "edit" + ] + }, + { + "required": [ + "resolveData" + ] + }, + { + "required": [ + "resolveToken" + ] + } + ] + } + } + ], + "$comment": "Runtime validates bounded JSON, diagnostics against the current bundle, action kinds against requested filters, and current-document edits. Host tokens cannot be supplied by providers. Labels, disabled reasons and other display text are never executable." + }, + "minItems": 0, + "maxItems": 100 + } + }, + "required": [ + "protocolVersion", + "requestId", + "scope", + "snapshot", + "kind", + "data" + ], + "additionalProperties": false } ] } diff --git a/schemas/manifest.schema.json b/schemas/manifest.schema.json index dd611c1..6518c80 100644 --- a/schemas/manifest.schema.json +++ b/schemas/manifest.schema.json @@ -189,12 +189,16 @@ "signature-help", "prepare-rename", "rename", + "format-document", + "format-range", + "code-actions", "completion-resolve", - "completion-snippets" + "completion-snippets", + "code-action-resolve" ] }, "minItems": 1, - "maxItems": 12, + "maxItems": 16, "uniqueItems": true }, "permissions": { @@ -325,6 +329,26 @@ } } } + }, + { + "if": { + "properties": { + "capabilities": { + "contains": { + "const": "code-action-resolve" + } + } + } + }, + "then": { + "properties": { + "capabilities": { + "contains": { + "const": "code-actions" + } + } + } + } } ] } diff --git a/scripts/pack-check.mjs b/scripts/pack-check.mjs index 3c0c132..c8237bb 100644 --- a/scripts/pack-check.mjs +++ b/scripts/pack-check.mjs @@ -102,6 +102,8 @@ assert.equal(typeof createDiagnosticsResult, 'function'); assert.match(run(process.execPath, ['assistance-example/run.mjs'], consumer), /nested signature help, lazy completion resolution and Unicode snippet preview verified/); await cp(join(installed, 'examples', 'workspace-edits'), join(consumer, 'edits-example'), {recursive: true}); assert.match(run(process.execPath, ['edits-example/run.mjs'], consumer), /rename preview, external-edit conflict, reviewed CAS and killed-process recovery verified/); + await cp(join(installed, 'examples', 'language-editing'), join(consumer, 'format-example'), {recursive: true}); + assert.match(run(process.execPath, ['format-example/run.mjs'], consumer), /range formatting, CRLF preservation, diagnostic quick-fix, conflict and selected approval verified/); await cp(join(installed, 'examples', 'project-session'), join(consumer, 'project-example'), {recursive: true}); run(process.execPath, ['project-example/run.mjs'], consumer); // Package and install a real independent plugin. npm sees generated metadata; @@ -198,6 +200,7 @@ try { await cp(join(root, 'tests', 'types', 'language.mts'), join(consumer, 'language.mts')); await cp(join(root, 'tests', 'types', 'language-assistance.mts'), join(consumer, 'language-assistance.mts')); await cp(join(root, 'tests', 'types', 'workspace-edits.mts'), join(consumer, 'workspace-edits.mts')); + await cp(join(root, 'tests', 'types', 'language-editing.mts'), join(consumer, 'language-editing.mts')); await cp(join(root, 'tests', 'types', 'workspace-project.mts'), join(consumer, 'workspace-project.mts')); await cp(join(root, 'tests', 'types', 'jobs-devtools.mts'), join(consumer, 'jobs-devtools.mts')); await cp(join(root, 'tests', 'types', 'workspace-observation.mts'), join(consumer, 'workspace-observation.mts')); diff --git a/src/contracts.mjs b/src/contracts.mjs index f72b09f..c291a0c 100644 --- a/src/contracts.mjs +++ b/src/contracts.mjs @@ -3,6 +3,7 @@ import { SEMVER_PATTERN } from './patterns.mjs'; import {parseCompletionItem, parseLanguageContext, parseSignatureHelp, completionEdits} from './language-assistance.mjs'; import {textIndex, languageText, languageCall} from './language-values.mjs'; import {parseWorkspaceEdit} from './workspace-edit-contracts.mjs'; +import {parseFormattingOptions, parseCodeActionContext, parseCodeActions, validateFormattingEdit, validateCodeActions} from './language-editing.mjs'; const encoder = new TextEncoder(); const identifierPattern = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/; @@ -11,8 +12,8 @@ const uriPattern = /^[A-Za-z][A-Za-z0-9+.-]*:[^\s\u0000-\u001f\u007f]+$/; const permissions = ['document.read', 'diagnostics.publish']; const workspacePermissions = ['workspace.read', 'workspace.write', 'backend.invoke']; const allPermissions = [...permissions, ...workspacePermissions, 'language.provide']; -export const LANGUAGE_FEATURES = Object.freeze(['completion', 'hover', 'definition', 'references', 'document-symbols', 'signature-help', 'prepare-rename', 'rename']); -export const LANGUAGE_CAPABILITIES = Object.freeze([...LANGUAGE_FEATURES, 'completion-resolve', 'completion-snippets']); +export const LANGUAGE_FEATURES = Object.freeze(['completion', 'hover', 'definition', 'references', 'document-symbols', 'signature-help', 'prepare-rename', 'rename', 'format-document', 'format-range', 'code-actions']); +export const LANGUAGE_CAPABILITIES = Object.freeze([...LANGUAGE_FEATURES, 'completion-resolve', 'completion-snippets', 'code-action-resolve']); function fail(path, message) { throw new PluginSdkError(ErrorCode.INVALID_CONTRACT, `${path}: ${message}`); @@ -173,6 +174,7 @@ export function parseManifest(value) { }; if (manifestVersion === 2) { if (output.capabilities.some(capability => ['completion-resolve', 'completion-snippets'].includes(capability)) && !output.capabilities.includes('completion')) fail('manifest.capabilities', 'completion extension requires completion'); + if (output.capabilities.includes('code-action-resolve') && !output.capabilities.includes('code-actions')) fail('manifest.capabilities', 'code action resolve requires code-actions'); output.runtime = enumeration(value.runtime, ['ui', 'workspace'], 'manifest.runtime'); if (output.runtime === 'ui' && output.permissions.some(permission => workspacePermissions.includes(permission))) fail('manifest.permissions', 'UI plugins cannot request workspace or backend permissions'); record(value.source, ['visibility', 'licenseFile'], ['repository'], 'manifest.source'); @@ -431,15 +433,16 @@ function inDocumentRange(value, text) { /** Language API v1: returned text is never HTML or a command. Snippets are explicit data. */ export function parseLanguageRequest(value) { value = input(value, LIMITS.requestBytes, 'request'); - record(value, ['protocolVersion', 'requestId', 'scope', 'snapshot', 'kind'], ['position', 'includeDeclaration', 'context', 'newName'], 'request'); + record(value, ['protocolVersion', 'requestId', 'scope', 'snapshot', 'kind'], ['position', 'includeDeclaration', 'context', 'newName', 'range', 'path', 'formatOptions', 'diagnosticContext'], 'request'); const kind = enumeration(value.kind, LANGUAGE_FEATURES, 'request.kind'); const output = { protocolVersion: version(value.protocolVersion, 'request.protocolVersion'), requestId: string(value.requestId, 128, 'request.requestId'), scope: scope(value.scope, 'request.scope'), snapshot: snapshot(value.snapshot, 'request.snapshot', true), kind, }; - if (kind === 'document-symbols') { - if (Object.hasOwn(value, 'position')) fail('request.position', 'document symbols do not take a position'); + const formatting = ['format-document', 'format-range'].includes(kind); + if (kind === 'document-symbols' || formatting || kind === 'code-actions') { + if (Object.hasOwn(value, 'position')) fail('request.position', 'this feature does not take a position'); } else { output.position = position(value.position, 'request.position'); inDocument(output.position, output.snapshot.text, 'request.position'); @@ -449,7 +452,21 @@ export function parseLanguageRequest(value) { if (kind !== 'references' || typeof value.includeDeclaration !== 'boolean') fail('request.includeDeclaration', 'only references accept a boolean'); output.includeDeclaration = value.includeDeclaration; } - if (Object.hasOwn(value, 'context')) output.context = parseLanguageContext(kind, value.context); + if (kind === 'code-actions') output.context = parseCodeActionContext(value.context); + else if (Object.hasOwn(value, 'context')) output.context = parseLanguageContext(kind, value.context); + if (formatting || kind === 'code-actions') output.path = parseWorkspacePath(value.path); + else if (Object.hasOwn(value, 'path')) fail('request.path', 'path is unsupported for this feature'); + if (kind === 'format-range' || kind === 'code-actions') { + output.range = range(value.range, 'request.range'); textIndex(output.snapshot.text).range(output.range); + } else if (Object.hasOwn(value, 'range')) fail('request.range', 'range is unsupported for this feature'); + if (formatting) output.formatOptions = parseFormattingOptions(value.formatOptions); + else if (Object.hasOwn(value, 'formatOptions')) fail('request.formatOptions', 'options are unsupported for this feature'); + if (kind === 'code-actions') { + record(value.diagnosticContext, ['revision', 'diagnostics'], [], 'request.diagnosticContext'); + const diagnostics = Object.freeze(array(value.diagnosticContext.diagnostics, LIMITS.maxDiagnostics, 'request.diagnosticContext.diagnostics', diagnostic)); + const index = textIndex(output.snapshot.text); for (const diagnostic of diagnostics) index.range(diagnostic.range); + output.diagnosticContext = Object.freeze({revision: integer(value.diagnosticContext.revision, Number.MAX_SAFE_INTEGER, 'request.diagnosticContext.revision'), diagnostics}); + } else if (Object.hasOwn(value, 'diagnosticContext')) fail('request.diagnosticContext', 'diagnostics are unsupported for this feature'); if (kind === 'rename') { output.newName = languageText(value.newName, 256); if (/[\u0000-\u001f\u007f]/.test(output.newName)) fail('request.newName', 'control characters are unsupported'); @@ -503,6 +520,8 @@ export function parseLanguageResult(value) { data: kind === 'signature-help' ? parseSignatureHelp(value.data) : kind === 'prepare-rename' ? renamePreparation(value.data) : kind === 'rename' ? value.data === null ? null : languageCall(() => parseWorkspaceEdit(value.data)) + : ['format-document', 'format-range'].includes(kind) ? value.data === null ? null : languageCall(() => parseWorkspaceEdit(value.data)) + : kind === 'code-actions' ? parseCodeActions(value.data) : kind === 'hover' ? hover(value.data, 'result.data') : Object.freeze(array(value.data, LIMITS.maxLanguageItems, 'result.data', readers[kind])), }), LIMITS.resultBytes, 'result'); } @@ -511,6 +530,8 @@ export function assertLanguageResultMatchesRequest(result, request) { assertIdentityMatches(result, request); if (result.kind !== request.kind) fail('result.kind', 'does not match request'); if (['signature-help', 'rename'].includes(result.kind)) return; + if (['format-document', 'format-range'].includes(result.kind)) {validateFormattingEdit(request, result.data); return;} + if (result.kind === 'code-actions') {validateCodeActions(request, result.data); return;} if (result.kind === 'prepare-rename') { if (result.data !== null) { textIndex(request.snapshot.text).range(result.data.range); diff --git a/src/host.mjs b/src/host.mjs index b0cb9a2..442f978 100644 --- a/src/host.mjs +++ b/src/host.mjs @@ -37,16 +37,24 @@ export function createPluginHost({hostId = 'test-host', scope = {projectId: 'exa let nextRequest = 0; let disposed = false; let commandCount = 0; + let diagnosticRevision = 0, diagnosticSequence = 0, acceptedDiagnosticSequence = 0; + let diagnosticItems = Object.freeze([]); const plugins = new Map(); const pending = new Set(); if (typeof binaryArtifacts !== 'boolean') throw error(ErrorCode.INVALID_CONTRACT, 'Invalid binary artifact capability'); const jobRegistry = createJobRegistry({scope, enabled: jobs, binaryArtifacts: binaryArtifacts && typeof workspace?.captureBinaryFile === 'function', jobStorage}); function isCurrent(request) { return !!document && request.scope.projectId === scope.projectId && request.scope.sessionId === scope.sessionId && - ['uri', 'languageId', 'modelVersion', 'workspaceRevision', 'text'].every(key => request.snapshot[key] === document[key]); + ['uri', 'languageId', 'modelVersion', 'workspaceRevision', 'text'].every(key => request.snapshot[key] === document[key]) && + (request.kind !== 'code-actions' || request.diagnosticContext.revision === diagnosticRevision); } const registry = createDiagnosticsRegistry({isCurrent}); const languages = new Map(LANGUAGE_FEATURES.map(kind => [kind, createLanguageRegistry(kind, {isCurrent})])); + function updateDiagnosticContext(items = [], resetSource = false) { + diagnosticItems = Object.freeze(items); diagnosticRevision++; + if (resetSource) acceptedDiagnosticSequence = ++diagnosticSequence; + languages.get('code-actions').invalidate(); + } function assertOpen() { if (disposed) throw error(ErrorCode.DISPOSED, 'Plugin host is disposed'); } function assertActive(state) { assertOpen(); if (!state.active || state.controller.signal.aborted) throw error(ErrorCode.DISPOSED, 'Plugin is deactivated'); } @@ -146,11 +154,16 @@ export function createPluginHost({hostId = 'test-host', scope = {projectId: 'exa if (!effectiveGrants.includes('document.read') || !effectiveGrants.includes('diagnostics.publish')) { throw error(ErrorCode.PERMISSION_DENIED, 'Diagnostics require document.read and diagnostics.publish grants'); } - let registration; - try { registration = registry.register(manifest.id, selector, provider); } + let rawRegistration; + try { rawRegistration = registry.register(manifest.id, selector, provider); } catch (failure) { throw error(failure instanceof PluginSdkError ? failure.code : ErrorCode.PROVIDER_FAILED, 'Diagnostics provider registration failed'); } + updateDiagnosticContext([], true); + let registered = true; + const registration = Object.freeze({dispose() { + if (!registered) return; registered = false; rawRegistration.dispose(); updateDiagnosticContext([], true); + }}); state.registrations.add(registration); return Object.freeze({dispose() { registration.dispose(); @@ -163,7 +176,7 @@ export function createPluginHost({hostId = 'test-host', scope = {projectId: 'exa if (!manifest.capabilities.includes(kind)) throw error(ErrorCode.PERMISSION_DENIED, 'Language capability was not declared'); let registration; try { registration = languages.get(kind).register(manifest.id, selector, provider, { - resolve: kind === 'completion' && manifest.capabilities.includes('completion-resolve'), + resolve: kind === 'completion' && manifest.capabilities.includes('completion-resolve') || kind === 'code-actions' && manifest.capabilities.includes('code-action-resolve'), snippets: kind === 'completion' && manifest.capabilities.includes('completion-snippets'), }); } catch (failure) { throw error(failure instanceof PluginSdkError ? failure.code : ErrorCode.PROVIDER_FAILED, 'Language provider registration failed'); } @@ -328,6 +341,7 @@ export function createPluginHost({hostId = 'test-host', scope = {projectId: 'exa } registry.invalidate(); for (const language of languages.values()) language.invalidate(); + diagnosticItems = Object.freeze([]); diagnosticRevision++; acceptedDiagnosticSequence = ++diagnosticSequence; document = updated; revision++; return updated; @@ -336,8 +350,10 @@ export function createPluginHost({hostId = 'test-host', scope = {projectId: 'exa assertOpen(); if (!document) throw error(ErrorCode.INVALID_CONTRACT, 'Set a document before requesting diagnostics'); const currentRevision = revision; + const sequence = ++diagnosticSequence; const result = await registry.request(parseDiagnosticsRequest({protocolVersion: 1, requestId: `request-${++nextRequest}`, scope, snapshot: document}), options); if (currentRevision !== revision) throw error(ErrorCode.STALE_SNAPSHOT, 'Document changed while diagnostics were pending'); + if (sequence >= acceptedDiagnosticSequence) {acceptedDiagnosticSequence = sequence; updateDiagnosticContext(result.diagnostics);} return result; }, async requestLanguage(kind, input = {}, options = {}) { @@ -345,14 +361,16 @@ export function createPluginHost({hostId = 'test-host', scope = {projectId: 'exa if (!document) throw error(ErrorCode.INVALID_CONTRACT, 'Set a document before requesting language features'); if (!languages.has(kind)) throw error(ErrorCode.INVALID_CONTRACT, 'Unknown language feature'); const value = parseJsonValue(input); - if (!value || Array.isArray(value) || typeof value !== 'object' || Object.keys(value).some(key => !['position', 'includeDeclaration', 'context', 'newName'].includes(key))) throw error(ErrorCode.INVALID_CONTRACT, 'Expected language request input'); - const request = parseLanguageRequest({protocolVersion: 1, requestId: `request-${++nextRequest}`, scope, snapshot: document, kind, ...value}); + if (!value || Array.isArray(value) || typeof value !== 'object' || Object.keys(value).some(key => !['position', 'includeDeclaration', 'context', 'newName', 'range', 'path', 'formatOptions'].includes(key))) throw error(ErrorCode.INVALID_CONTRACT, 'Expected language request input'); + const diagnostics = kind === 'code-actions' ? {diagnosticContext: {revision: diagnosticRevision, diagnostics: diagnosticItems}} : {}; + const request = parseLanguageRequest({protocolVersion: 1, requestId: `request-${++nextRequest}`, scope, snapshot: document, kind, ...value, ...diagnostics}); const currentRevision = revision; + const currentDiagnostics = diagnosticRevision; const result = await languages.get(kind).request(request, options); - if (revision !== currentRevision) throw error(ErrorCode.STALE_SNAPSHOT, 'Document changed while language features were pending'); + if (revision !== currentRevision || kind === 'code-actions' && diagnosticRevision !== currentDiagnostics) throw error(ErrorCode.STALE_SNAPSHOT, 'Document or diagnostics changed while language features were pending'); return result; }, - languageCapabilities() {assertOpen(); return Object.freeze({protocolVersion: 1, features: LANGUAGE_FEATURES, completionResolve: true, snippets: true, positions: 'utf16-zero-based', limits: LANGUAGE_LIMITS});}, + languageCapabilities() {assertOpen(); return Object.freeze({protocolVersion: 1, features: LANGUAGE_FEATURES, completionResolve: true, codeActionResolve: true, snippets: true, positions: 'utf16-zero-based', limits: LANGUAGE_LIMITS});}, async resolveCompletion(token, options = {}) { assertOpen(); const currentRevision = revision; const result = await languages.get('completion').resolve(token, options); @@ -361,6 +379,19 @@ export function createPluginHost({hostId = 'test-host', scope = {projectId: 'exa }, prepareCompletion(result, itemIndex = 0) {assertOpen(); return languages.get('completion').prepareCompletion(result, itemIndex);}, releaseCompletion(result) {assertOpen(); languages.get('completion').releaseCompletion(result);}, + async resolveCodeAction(token, options = {}) { + assertOpen(); const currentRevision = revision, currentDiagnostics = diagnosticRevision; + const result = await languages.get('code-actions').resolve(token, options); + if (revision !== currentRevision || diagnosticRevision !== currentDiagnostics) throw error(ErrorCode.STALE_SNAPSHOT, 'Document or diagnostics changed while action resolution was pending'); + return result; + }, + prepareCodeAction(result, itemIndex = 0) {assertOpen(); return languages.get('code-actions').prepareEdit(result, itemIndex);}, + releaseCodeActions(result) {assertOpen(); languages.get('code-actions').release(result);}, + prepareFormatting(result) { + assertOpen(); const kind = Object.getOwnPropertyDescriptor(result ?? {}, 'kind')?.value; + if (!['format-document', 'format-range'].includes(kind)) throw error(ErrorCode.INVALID_CONTRACT, 'Expected a formatting result'); + return languages.get(kind).prepareEdit(result); + }, listPlugins() {assertOpen(); return Object.freeze([...plugins.values()].filter(state => state.ready && state.active).map(state => state.manifest));}, listCommands() { assertOpen(); diff --git a/src/index.d.mts b/src/index.d.mts index 1b5526c..91b001f 100644 --- a/src/index.d.mts +++ b/src/index.d.mts @@ -165,6 +165,10 @@ export interface PluginHost extends Disposable { resolveCompletion(token: string, options?: RequestOptions): Promise>; prepareCompletion(result: LanguageResult<'completion'>, itemIndex?: number): CompletionInsertion; releaseCompletion(result: LanguageResult<'completion'>): void; + resolveCodeAction(token: string, options?: RequestOptions): Promise>; + prepareCodeAction(result: LanguageResult<'code-actions'>, itemIndex?: number): import('./workspace-edits.mjs').WorkspaceEdit; + releaseCodeActions(result: LanguageResult<'code-actions'>): void; + prepareFormatting(result: LanguageResult<'format-document' | 'format-range'>): import('./workspace-edits.mjs').WorkspaceEdit | null; listCommands(): readonly RegisteredCommand[]; executeCommand(pluginId: string, commandId: string, input: JsonValue, options?: RequestOptions): Promise; jobCapabilities(): JobCapabilities; @@ -192,18 +196,22 @@ export interface PluginHost extends Disposable { /** Executes trusted plugins in-process. Ports enforce file/backend authority; this is not a sandbox. */ export function createPluginHost(options?: PluginHostOptions): PluginHost; -export type LanguageFeature = 'completion' | 'hover' | 'definition' | 'references' | 'document-symbols' | 'signature-help' | 'prepare-rename' | 'rename'; -export type LanguageCapability = LanguageFeature | 'completion-resolve' | 'completion-snippets'; +export type FormattingFeature = 'format-document' | 'format-range'; +export type LanguageFeature = 'completion' | 'hover' | 'definition' | 'references' | 'document-symbols' | 'signature-help' | 'prepare-rename' | 'rename' | FormattingFeature | 'code-actions'; +export type LanguageCapability = LanguageFeature | 'completion-resolve' | 'completion-snippets' | 'code-action-resolve'; export const LANGUAGE_FEATURES: readonly LanguageFeature[]; export const LANGUAGE_CAPABILITIES: readonly LanguageCapability[]; -export type LanguageInput = K extends 'document-symbols' +export type LanguageInput = K extends FormattingFeature + ? {readonly path: string; readonly formatOptions: FormattingOptions; readonly position?: never} & (K extends 'format-range' ? {readonly range: Range} : {readonly range?: never}) + : K extends 'code-actions' ? {readonly path: string; readonly range: Range; readonly context?: CodeActionContext; readonly position?: never} + : K extends 'document-symbols' ? {readonly position?: never; readonly includeDeclaration?: never; readonly context?: never; readonly newName?: never} : {readonly position: Position} & (K extends 'references' ? {readonly includeDeclaration?: boolean; readonly context?: never} : K extends 'completion' ? {readonly includeDeclaration?: never; readonly context?: CompletionContext} : K extends 'signature-help' ? {readonly includeDeclaration?: never; readonly context?: SignatureHelpContext} : {readonly includeDeclaration?: never; readonly context?: never}) & (K extends 'rename' ? {readonly newName: string} : {readonly newName?: never}); export type LanguageRequest = K extends LanguageFeature - ? DiagnosticsRequest & {readonly kind: K} & LanguageInput : never; + ? DiagnosticsRequest & {readonly kind: K} & LanguageInput & (K extends 'code-actions' ? {readonly diagnosticContext: {readonly revision: number; readonly diagnostics: readonly Diagnostic[]}} : {}) : never; /** Literal by default. A declared snippet capability enables the bounded data-only subset. */ export interface CompletionItem { readonly label: string; readonly insertText: string; readonly detail?: string; readonly range?: Range; @@ -234,10 +242,26 @@ export const LANGUAGE_LIMITS: Readonly<{ }>; export interface LanguageCapabilities { readonly protocolVersion: 1; readonly features: readonly LanguageFeature[]; - readonly completionResolve: true; readonly snippets: true; readonly positions: 'utf16-zero-based'; readonly limits: typeof LANGUAGE_LIMITS; + readonly completionResolve: true; readonly codeActionResolve: true; readonly snippets: true; readonly positions: 'utf16-zero-based'; readonly limits: typeof LANGUAGE_LIMITS; } export function parseSnippet(value: unknown): ParsedSnippet; export function parseCompletionItem(value: unknown): CompletionItem; +export interface FormattingOptions { + readonly tabSize: number; readonly insertSpaces: boolean; readonly trimTrailingWhitespace?: boolean; + readonly insertFinalNewline?: boolean; readonly trimFinalNewlines?: boolean; readonly endOfLine?: 'preserve' | 'lf' | 'crlf'; +} +export type CodeActionKind = 'quickfix' | 'refactor' | 'refactor.extract' | 'refactor.inline' | 'refactor.rewrite' | 'source.organizeImports' | 'source.fixAll'; +export const CODE_ACTION_KINDS: readonly CodeActionKind[]; +export const LANGUAGE_EDIT_LIMITS: Readonly<{actions: 100; diagnosticIndices: 500; title: 256; disabledReason: 2048; tabSize: 16}>; +export interface CodeActionContext {readonly triggerKind: 'invoked' | 'automatic'; readonly only?: readonly CodeActionKind[];} +export interface CodeAction { + readonly title: string; readonly kind: CodeActionKind; readonly isPreferred?: boolean; + readonly disabled?: {readonly reason: string}; readonly diagnosticIndices?: readonly number[]; + readonly edit?: import('./workspace-edits.mjs').WorkspaceEdit; + readonly resolveData?: JsonValue; readonly resolveToken?: string; +} +export function parseFormattingOptions(value: unknown): FormattingOptions; +export function parseCodeAction(value: unknown): CodeAction; export function parseSignatureHelp(value: unknown): SignatureHelp | null; export interface Hover {readonly text: string; readonly range?: Range;} export interface RenamePreparation {readonly range: Range; readonly placeholder: string;} @@ -256,17 +280,23 @@ export interface LanguageData { readonly 'signature-help': SignatureHelp | null; readonly 'prepare-rename': RenamePreparation | null; readonly rename: import('./workspace-edits.mjs').WorkspaceEdit | null; + readonly 'format-document': import('./workspace-edits.mjs').WorkspaceEdit | null; + readonly 'format-range': import('./workspace-edits.mjs').WorkspaceEdit | null; + readonly 'code-actions': readonly CodeAction[]; } export type LanguageResult = K extends LanguageFeature ? Omit & {readonly kind: K; readonly data: LanguageData[K]} : never; export interface LanguageProvider { provide(request: LanguageRequest, options: {readonly signal: AbortSignal}): LanguageResult | Promise>; - readonly resolve?: K extends 'completion' ? (request: LanguageRequest<'completion'>, item: CompletionItem, options: {readonly signal: AbortSignal}) => CompletionItem | Promise : never; + readonly resolve?: K extends 'completion' ? (request: LanguageRequest<'completion'>, item: CompletionItem, options: {readonly signal: AbortSignal}) => CompletionItem | Promise + : K extends 'code-actions' ? (request: LanguageRequest<'code-actions'>, item: CodeAction, options: {readonly signal: AbortSignal}) => CodeAction | Promise : never; } export interface LanguageRegistry extends Disposable { - register(pluginId: string, selector: ProviderSelector, provider: LanguageProvider, options?: K extends 'completion' ? {readonly resolve?: boolean; readonly snippets?: boolean} : never): Disposable; + register(pluginId: string, selector: ProviderSelector, provider: LanguageProvider, options?: K extends 'completion' ? {readonly resolve?: boolean; readonly snippets?: boolean} : K extends 'code-actions' ? {readonly resolve?: boolean} : never): Disposable; request(request: LanguageRequest, options?: RequestOptions): Promise>; - resolve(token: string, options?: RequestOptions): Promise>; + resolve(token: string, options?: RequestOptions): Promise>; + prepareEdit(result: K extends FormattingFeature | 'code-actions' ? LanguageResult : never, itemIndex?: number): import('./workspace-edits.mjs').WorkspaceEdit | null; + release(result: LanguageResult): void; prepareCompletion(result: LanguageResult<'completion'>, itemIndex?: number): CompletionInsertion; releaseCompletion(result: LanguageResult<'completion'>): void; invalidate(): void; diff --git a/src/index.mjs b/src/index.mjs index 803bb9d..ca17ec7 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -8,6 +8,7 @@ export { createDiagnosticsRegistry } from './lifecycle.mjs'; export { createLanguageRegistry } from './lifecycle.mjs'; export { LANGUAGE_FEATURES, LANGUAGE_CAPABILITIES, parseLanguageRequest, parseLanguageResult, createLanguageResult } from './contracts.mjs'; export {LANGUAGE_LIMITS, parseSnippet, parseCompletionItem, parseSignatureHelp} from './language-assistance.mjs'; +export {CODE_ACTION_KINDS, LANGUAGE_EDIT_LIMITS, parseFormattingOptions, parseCodeAction} from './language-editing.mjs'; export { createPluginHost } from './host.mjs'; /** Declare an ESM plugin. Activation is performed by a host with explicit grants. */ diff --git a/src/language-assistance-schemas.mjs b/src/language-assistance-schemas.mjs index 92264fc..19113dc 100644 --- a/src/language-assistance-schemas.mjs +++ b/src/language-assistance-schemas.mjs @@ -25,5 +25,5 @@ export const LANGUAGE_ASSISTANCE_SCHEMAS = Object.freeze({ 'completion-item': COMPLETION_ITEM_SCHEMA, 'signature-help': SIGNATURE_HELP_SCHEMA, 'language-snippet': {...text(limits.snippetChars), $comment: 'Runtime accepts $n, ${n}, ${n:literal}, one $0 and escapes for backslash/dollar/braces. Indices are 0..99; no variables, nesting, choices or transforms. Mirrored defaults must agree; expansion is <=16384 UTF-16 units and <=128 explicit stops.'}, - 'language-capabilities': object({protocolVersion: {const: 1}, features: {const: LANGUAGE_FEATURES}, completionResolve: {const: true}, snippets: {const: true}, positions: {const: 'utf16-zero-based'}, limits: object(Object.fromEntries(Object.entries(limits).map(([key, value]) => [key, {const: value}])))}), + 'language-capabilities': object({protocolVersion: {const: 1}, features: {const: LANGUAGE_FEATURES}, completionResolve: {const: true}, codeActionResolve: {const: true}, snippets: {const: true}, positions: {const: 'utf16-zero-based'}, limits: object(Object.fromEntries(Object.entries(limits).map(([key, value]) => [key, {const: value}])))}), }); diff --git a/src/language-editing-schemas.mjs b/src/language-editing-schemas.mjs new file mode 100644 index 0000000..252453f --- /dev/null +++ b/src/language-editing-schemas.mjs @@ -0,0 +1,14 @@ +import {CODE_ACTION_KINDS, LANGUAGE_EDIT_LIMITS as limits} from './language-editing.mjs'; +import {WORKSPACE_EDIT_SCHEMA} from './workspace-edit-schemas.mjs'; +const object = (properties, required = Object.keys(properties)) => ({type: 'object', properties, required, additionalProperties: false}); +const text = maximum => ({type: 'string', minLength: 1, maxLength: maximum}); +const integer = (maximum, minimum = 0) => ({type: 'integer', minimum, maximum}); +const list = (items, maxItems) => ({type: 'array', items, maxItems}); +export const FORMATTING_OPTIONS_SCHEMA = object({tabSize: integer(limits.tabSize, 1), insertSpaces: {type: 'boolean'}, trimTrailingWhitespace: {type: 'boolean'}, insertFinalNewline: {type: 'boolean'}, trimFinalNewlines: {type: 'boolean'}, endOfLine: {enum: ['preserve', 'lf', 'crlf']}}, ['tabSize', 'insertSpaces']); +export const CODE_ACTION_CONTEXT_SCHEMA = object({triggerKind: {enum: ['invoked', 'automatic']}, only: {...list({enum: CODE_ACTION_KINDS}, CODE_ACTION_KINDS.length), minItems: 1, uniqueItems: true}}, ['triggerKind']); +export const CODE_ACTION_SCHEMA = { + ...object({title: text(limits.title), kind: {enum: CODE_ACTION_KINDS}, isPreferred: {type: 'boolean'}, disabled: object({reason: text(limits.disabledReason)}), diagnosticIndices: {...list(integer(limits.diagnosticIndices - 1), limits.diagnosticIndices), uniqueItems: true}, edit: WORKSPACE_EDIT_SCHEMA, resolveData: {'x-maxUtf8Bytes': 4096}, resolveToken: {...text(36), pattern: '^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$'}}, ['title', 'kind']), + allOf: [{if: {required: ['disabled']}, then: {properties: {edit: false, resolveData: false, resolveToken: false}}, else: {anyOf: [{required: ['edit']}, {required: ['resolveData']}, {required: ['resolveToken']}]}}], + $comment: 'Runtime validates bounded JSON, diagnostics against the current bundle, action kinds against requested filters, and current-document edits. Host tokens cannot be supplied by providers. Labels, disabled reasons and other display text are never executable.', +}; +export const LANGUAGE_EDITING_SCHEMAS = Object.freeze({'formatting-options': FORMATTING_OPTIONS_SCHEMA, 'code-action': CODE_ACTION_SCHEMA}); diff --git a/src/language-editing.mjs b/src/language-editing.mjs new file mode 100644 index 0000000..fabaf6e --- /dev/null +++ b/src/language-editing.mjs @@ -0,0 +1,80 @@ +import {languageCopy, languageObject, languageText, languageInteger, languageList, compareLanguagePosition, textIndex, invalidLanguage, languageCall} from './language-values.mjs'; +import {parseWorkspaceEdit, canonicalEditJson, editHash} from './workspace-edit-contracts.mjs'; +import {applyTextEdits} from './text-edits.mjs'; +import {LANGUAGE_LIMITS} from './language-assistance.mjs'; + +export const CODE_ACTION_KINDS = Object.freeze(['quickfix', 'refactor', 'refactor.extract', 'refactor.inline', 'refactor.rewrite', 'source.organizeImports', 'source.fixAll']); +export const LANGUAGE_EDIT_LIMITS = Object.freeze({actions: 100, diagnosticIndices: 500, title: 256, disabledReason: 2048, tabSize: 16}); +export function parseFormattingOptions(input) { + const value = languageCopy(input, 2048); + languageObject(value, ['tabSize', 'insertSpaces'], ['trimTrailingWhitespace', 'insertFinalNewline', 'trimFinalNewlines', 'endOfLine']); + languageInteger(value.tabSize, LANGUAGE_EDIT_LIMITS.tabSize, 1); + for (const key of ['insertSpaces', 'trimTrailingWhitespace', 'insertFinalNewline', 'trimFinalNewlines']) if (value[key] !== undefined && typeof value[key] !== 'boolean') invalidLanguage(); + if (value.endOfLine !== undefined && !['preserve', 'lf', 'crlf'].includes(value.endOfLine)) invalidLanguage(); + return value; +} +export function parseCodeActionContext(input = {triggerKind: 'invoked'}) { + const value = languageCopy(input, 2048); languageObject(value, ['triggerKind'], ['only']); + if (!['invoked', 'automatic'].includes(value.triggerKind)) invalidLanguage(); + if (value.only !== undefined) { + for (const kind of languageList(value.only, CODE_ACTION_KINDS.length, 1)) if (!CODE_ACTION_KINDS.includes(kind)) invalidLanguage(); + if (new Set(value.only).size !== value.only.length) invalidLanguage(); + } + return value; +} +export function parseCodeAction(input) { + const value = languageCopy(input, 1_100_000); + languageObject(value, ['title', 'kind'], ['isPreferred', 'disabled', 'diagnosticIndices', 'edit', 'resolveData', 'resolveToken']); + languageText(value.title, LANGUAGE_EDIT_LIMITS.title); + if (!CODE_ACTION_KINDS.includes(value.kind) || value.isPreferred !== undefined && typeof value.isPreferred !== 'boolean') invalidLanguage(); + if (value.diagnosticIndices !== undefined) { + for (const index of languageList(value.diagnosticIndices, LANGUAGE_EDIT_LIMITS.diagnosticIndices)) languageInteger(index, LANGUAGE_EDIT_LIMITS.diagnosticIndices - 1); + if (new Set(value.diagnosticIndices).size !== value.diagnosticIndices.length) invalidLanguage(); + } + if (value.disabled !== undefined) { + languageObject(value.disabled, ['reason']); languageText(value.disabled.reason, LANGUAGE_EDIT_LIMITS.disabledReason); + if (value.edit !== undefined || value.resolveData !== undefined || value.resolveToken !== undefined) invalidLanguage('Disabled actions cannot carry an edit or resolver'); + } else if (value.edit === undefined && value.resolveData === undefined && value.resolveToken === undefined) invalidLanguage('Action requires a proposed edit or resolver'); + if (value.edit !== undefined) languageCall(() => parseWorkspaceEdit(value.edit)); + if (value.resolveData !== undefined) languageCopy(value.resolveData, LANGUAGE_LIMITS.resolveDataBytes); + if (value.resolveToken !== undefined && (typeof value.resolveToken !== 'string' || !/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(value.resolveToken))) invalidLanguage(); + return value; +} +export function parseCodeActions(input) { + const values = languageCopy(input); + return Object.freeze(languageList(values, LANGUAGE_EDIT_LIMITS.actions).map(parseCodeAction)); +} +export function sameCodeActionSelection(a, b) { + return a.title === b.title && a.kind === b.kind && (a.isPreferred ?? false) === (b.isPreferred ?? false) && canonicalEditJson(a.diagnosticIndices ?? []) === canonicalEditJson(b.diagnosticIndices ?? []); +} +export function validateActionEdit(request, edit) { + for (const change of edit.changes) if (change.path === request.path) { + if (change.baseRevision !== undefined && change.baseRevision !== editHash(request.snapshot.text)) invalidLanguage('Action edit has a different current-document base'); + if (change.kind === 'edit') languageCall(() => applyTextEdits(request.snapshot.text, change.edits)); + } +} +export function validateCodeActions(request, items) { + for (const item of items) { + if (request.context?.only && !request.context.only.some(kind => item.kind === kind || item.kind.startsWith(kind + '.'))) invalidLanguage('Action kind does not match the requested filter'); + for (const index of item.diagnosticIndices ?? []) if (index >= request.diagnosticContext.diagnostics.length) invalidLanguage('Action diagnostic reference is outside the current bundle'); + if (item.edit !== undefined) validateActionEdit(request, item.edit); + } +} +/** Validate scope and newline policy; return whether the formatter changes content. */ +export function validateFormattingEdit(request, edit) { + if (edit === null) return false; + if (edit.changes.length !== 1) invalidLanguage('Formatting affects one current document'); + const change = edit.changes[0]; + if (change.kind !== 'edit' || change.path !== request.path || change.baseRevision !== editHash(request.snapshot.text)) invalidLanguage('Formatting must match the current file snapshot'); + const index = textIndex(request.snapshot.text); + for (const item of change.edits) { + index.range(item.range); + if (request.kind === 'format-range' && (compareLanguagePosition(item.range.start, request.range.start) < 0 || compareLanguagePosition(item.range.end, request.range.end) > 0)) invalidLanguage('Formatting edit is outside the selected range'); + } + const content = languageCall(() => applyTextEdits(request.snapshot.text, change.edits)); + const originalEndings = [...request.snapshot.text.matchAll(/\r\n|\r|\n/g)].map(match => match[0]); + const policy = request.formatOptions.endOfLine ?? 'preserve'; + const allowed = new Set(policy === 'lf' ? ['\n'] : policy === 'crlf' ? ['\r\n'] : originalEndings.length ? originalEndings : ['\n']); + for (const item of change.edits) if ([...item.text.matchAll(/\r\n|\r|\n/g)].some(match => !allowed.has(match[0]))) invalidLanguage('Formatter changed the requested line ending policy'); + return content !== request.snapshot.text; +} diff --git a/src/language-registry.mjs b/src/language-registry.mjs index 0c7bf32..2c01b08 100644 --- a/src/language-registry.mjs +++ b/src/language-registry.mjs @@ -2,6 +2,7 @@ import {parseProviderSelector, parseLanguageRequest, parseLanguageResult, assert import {LANGUAGE_LIMITS, parseCompletionItem, completionInsertion} from './language-assistance.mjs'; import {languageObject, languageInteger, invalidLanguage} from './language-values.mjs'; import {ErrorCode, LIMITS, PluginSdkError} from './limits.mjs'; +import {parseCodeAction, sameCodeActionSelection, validateFormattingEdit} from './language-editing.mjs'; const failure = (code, message) => new PluginSdkError(code, message); const sameRange = (a, b) => ['start', 'end'].every(key => a[key].line === b[key].line && a[key].character === b[key].character); @@ -19,6 +20,7 @@ function providerMethod(provider, name) { export function createLanguageRegistry(kind, {isCurrent = () => true} = {}) { if (!LANGUAGE_FEATURES.includes(kind) || typeof isCurrent !== 'function') invalidLanguage(); const registrations = new Set(), live = new Set(), tokens = new Map(), results = new WeakMap(); + const resolvable = kind === 'completion' || kind === 'code-actions'; let disposed = false, sequence = 0, epoch = 0, tokenBytes = 0, resolving = 0; const open = () => {if (disposed) throw failure(ErrorCode.DISPOSED, 'Language registry is disposed');}; const current = (request, expectedEpoch = epoch) => { @@ -34,10 +36,10 @@ export function createLanguageRegistry(kind, {isCurrent = () => true} = {}) { if (typeof pluginId !== 'string' || !pluginId.length || pluginId.length > 128) invalidLanguage(); selector = parseProviderSelector(selector); languageObject(options, [], ['resolve', 'snippets']); for (const value of Object.values(options)) if (typeof value !== 'boolean') invalidLanguage(); - if (kind !== 'completion' && (options.resolve || options.snippets)) invalidLanguage(); + if (!resolvable && options.resolve || kind !== 'completion' && options.snippets) invalidLanguage(); const provide = providerMethod(provider, 'provide'), resolve = providerMethod(provider, 'resolve'); if (!provide) invalidLanguage('Expected provider data methods'); - if (resolve && !options.resolve || options.resolve && !resolve) throw failure(ErrorCode.PERMISSION_DENIED, 'Completion resolve needs an explicit capability'); + if (resolve && !options.resolve || options.resolve && !resolve) throw failure(ErrorCode.PERMISSION_DENIED, 'Language resolve needs an explicit capability'); if (registrations.size >= LIMITS.maxRegistrations) throw failure(ErrorCode.BUDGET_EXCEEDED, 'Language provider limit exceeded'); const entry = {pluginId, selector, provider, provide, resolve, snippets: options.snippets === true, sequence: sequence++, active: true}; registrations.add(entry); @@ -84,15 +86,16 @@ export function createLanguageRegistry(kind, {isCurrent = () => true} = {}) { } function checkedResult(raw, request, entry, allowTokens) { let result = parseLanguageResult(raw); assertLanguageResultMatchesRequest(result, request); + if (['format-document', 'format-range'].includes(kind) && !validateFormattingEdit(request, result.data)) result = parseLanguageResult({...result, data: null}); const additions = []; let bytes = 0; - if (kind === 'completion') { + if (resolvable) { purgeTokens(); const data = result.data.map(item => { if (item.resolveToken !== undefined) invalidLanguage('Providers cannot supply host resolve tokens'); - if (item.insertTextFormat === 'snippet' && !entry.snippets) throw failure(ErrorCode.PERMISSION_DENIED, 'Snippet capability was not declared'); + if (kind === 'completion' && item.insertTextFormat === 'snippet' && !entry.snippets) throw failure(ErrorCode.PERMISSION_DENIED, 'Snippet capability was not declared'); const {resolveData, ...visible} = item; if (resolveData !== undefined && allowTokens) { - if (!entry.resolve) throw failure(ErrorCode.CAPABILITY_UNAVAILABLE, 'Completion resolver is unavailable'); + if (!entry.resolve) throw failure(ErrorCode.CAPABILITY_UNAVAILABLE, 'Language resolver is unavailable'); if (typeof globalThis.crypto?.randomUUID !== 'function') throw failure(ErrorCode.CAPABILITY_UNAVAILABLE, 'Secure token generation is unavailable'); const id = globalThis.crypto.randomUUID(), size = new TextEncoder().encode(JSON.stringify({request, item})).byteLength; additions.push([id, {provider: entry, request, item, epoch, bytes: size, expiresAt: Date.now() + LANGUAGE_LIMITS.resolveTtlMs}]); bytes += size; @@ -100,13 +103,19 @@ export function createLanguageRegistry(kind, {isCurrent = () => true} = {}) { } return visible; }); - if (tokens.size + additions.length > LANGUAGE_LIMITS.resolveTokens || tokenBytes + bytes > LANGUAGE_LIMITS.resolveBytes) throw failure(ErrorCode.BUDGET_EXCEEDED, 'Completion resolve retention limit exceeded'); + if (tokens.size + additions.length > LANGUAGE_LIMITS.resolveTokens || tokenBytes + bytes > LANGUAGE_LIMITS.resolveBytes) throw failure(ErrorCode.BUDGET_EXCEEDED, 'Language resolve retention limit exceeded'); result = parseLanguageResult({...result, data}); } for (const [id, entry] of additions) tokens.set(id, entry); tokenBytes += bytes; results.set(result, {request, entry, epoch, tokenIds: additions.map(([id]) => id)}); return result; } + function releaseResult(result) { + open(); + const saved = results.get(result); if (!saved) return; + for (const id of saved.tokenIds) removeToken(id); + results.delete(result); + } return Object.freeze({ register, async request(input, options) { @@ -121,17 +130,19 @@ export function createLanguageRegistry(kind, {isCurrent = () => true} = {}) { }, async resolve(token, options) { open(); purgeTokens(); - if (kind !== 'completion') throw failure(ErrorCode.CAPABILITY_UNAVAILABLE, 'This feature has no resolver'); + if (!resolvable) throw failure(ErrorCode.CAPABILITY_UNAVAILABLE, 'This feature has no resolver'); if (typeof token !== 'string' || token.length !== 36) invalidLanguage('Invalid completion resolve token'); const saved = tokens.get(token); if (!saved) throw failure(ErrorCode.STALE_SNAPSHOT, 'Completion resolve token is unknown or expired'); current(saved.request, saved.epoch); removeToken(token); const raw = await invoke(saved.provider, saved.request, options => saved.provider.resolve.call(saved.provider.provider, saved.request, saved.item, options), options, true); current(saved.request, saved.epoch); - const item = parseCompletionItem(raw), previous = saved.item; - const initialRange = previous.range ?? {start: saved.request.position, end: saved.request.position}; - const resolvedRange = item.range ?? {start: saved.request.position, end: saved.request.position}; - if (item.label !== previous.label || item.insertText !== previous.insertText || (item.insertTextFormat ?? 'literal') !== (previous.insertTextFormat ?? 'literal') || !sameRange(initialRange, resolvedRange)) invalidLanguage('Completion resolver changed the selected insertion'); + const item = kind === 'completion' ? parseCompletionItem(raw) : parseCodeAction(raw), previous = saved.item; + if (kind === 'completion') { + const initialRange = previous.range ?? {start: saved.request.position, end: saved.request.position}; + const resolvedRange = item.range ?? {start: saved.request.position, end: saved.request.position}; + if (item.label !== previous.label || item.insertText !== previous.insertText || (item.insertTextFormat ?? 'literal') !== (previous.insertTextFormat ?? 'literal') || !sameRange(initialRange, resolvedRange)) invalidLanguage('Completion resolver changed the selected insertion'); + } else if (!sameCodeActionSelection(item, previous)) invalidLanguage('Resolver changed the selected action'); return checkedResult(createLanguageResult(saved.request, [item]), saved.request, saved.provider, false); }, prepareCompletion(result, itemIndex = 0) { @@ -143,12 +154,21 @@ export function createLanguageRegistry(kind, {isCurrent = () => true} = {}) { languageInteger(itemIndex, result.data.length - 1); return completionInsertion(saved.request, result.data[itemIndex]); }, - releaseCompletion(result) { + prepareEdit(result, itemIndex = 0) { open(); - const saved = results.get(result); if (!saved) return; - for (const id of saved.tokenIds) removeToken(id); - results.delete(result); + if (!['format-document', 'format-range', 'code-actions'].includes(kind)) invalidLanguage(); + const saved = results.get(result); + if (!saved) throw failure(ErrorCode.STALE_SNAPSHOT, 'Edit result does not belong to this registry'); + current(saved.request, saved.epoch); + if (!saved.entry.active) throw failure(ErrorCode.DISPOSED, 'Edit provider is no longer active'); + if (kind !== 'code-actions') return result.data; + languageInteger(itemIndex, result.data.length - 1); + const item = result.data[itemIndex]; + if (item.disabled || item.edit === undefined) throw failure(ErrorCode.CAPABILITY_UNAVAILABLE, 'Action has no available edit'); + return item.edit; }, + releaseCompletion: releaseResult, + release: releaseResult, invalidate() { open(); epoch++; clearTokens(); for (const ticket of live) ticket.controller.abort(failure(ErrorCode.STALE_SNAPSHOT, 'Language snapshot is no longer current')); diff --git a/src/schemas.mjs b/src/schemas.mjs index 75996e1..180795c 100644 --- a/src/schemas.mjs +++ b/src/schemas.mjs @@ -17,6 +17,7 @@ import { LANGUAGE_FEATURES, LANGUAGE_CAPABILITIES } from './contracts.mjs'; import {LANGUAGE_ASSISTANCE_SCHEMAS, COMPLETION_ITEM_SCHEMA, SIGNATURE_HELP_SCHEMA, languageContextSchema} from './language-assistance-schemas.mjs'; import {SDK_VERSION} from './version.mjs'; import {WORKSPACE_EDIT_SCHEMA, WORKSPACE_EDIT_SCHEMAS} from './workspace-edit-schemas.mjs'; +import {FORMATTING_OPTIONS_SCHEMA, CODE_ACTION_SCHEMA, CODE_ACTION_CONTEXT_SCHEMA, LANGUAGE_EDITING_SCHEMAS} from './language-editing-schemas.mjs'; const schema = 'https://json-schema.org/draft/2020-12/schema'; const base = `https://github.com/Altifigence/dds-plugin-sdk/blob/v${SDK_VERSION}/schemas/`; @@ -63,6 +64,7 @@ const manifestV2 = object({ }); manifestV2.allOf = [{if: {properties: {runtime: {const: 'ui'}}}, then: {properties: {permissions: {items: {enum: ['document.read', 'diagnostics.publish', 'language.provide']}}}}}]; manifestV2.allOf.push({if: {properties: {capabilities: {contains: {enum: ['completion-resolve', 'completion-snippets']}}}}, then: {properties: {capabilities: {contains: {const: 'completion'}}}}}); +manifestV2.allOf.push({if: {properties: {capabilities: {contains: {const: 'code-action-resolve'}}}}, then: {properties: {capabilities: {contains: {const: 'code-actions'}}}}}); const commandParameter = object({ name: identifier(128), label: text(128), type: {enum: ['string', 'number', 'boolean']}, required: {type: 'boolean'}, choices: list(text(256), 32, 1, true), @@ -80,6 +82,9 @@ const languageData = { 'signature-help': SIGNATURE_HELP_SCHEMA, 'prepare-rename': {oneOf: [{type: 'null'}, object({range, placeholder: text(256)})]}, rename: {oneOf: [{type: 'null'}, WORKSPACE_EDIT_SCHEMA]}, + 'format-document': {oneOf: [{type: 'null'}, WORKSPACE_EDIT_SCHEMA]}, + 'format-range': {oneOf: [{type: 'null'}, WORKSPACE_EDIT_SCHEMA]}, + 'code-actions': list(CODE_ACTION_SCHEMA, 100), hover: {oneOf: [{type: 'null'}, object({text: text(16_384), range}, ['text'])]}, definition: list(location, LIMITS.maxLanguageItems), references: list(location, LIMITS.maxLanguageItems), 'document-symbols': list(object({name: text(256), kind: {enum: ['module', 'namespace', 'class', 'interface', 'function', 'method', 'variable', 'constant', 'property', 'type']}, range, selectionRange: range, detail: text(2048)}, ['name', 'kind', 'range', 'selectionRange']), LIMITS.maxLanguageItems), @@ -108,6 +113,7 @@ const historyDisposition = {enum:['live','completed','interrupted','expired']}; const historyCursor = {...text(40),pattern:'^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}:(?:0|[1-9][0-9]{0,2})$'}; const historyItem = object({jobId,commandId:text(128),state:{enum:JOB_STATES},disposition:historyDisposition,startedAt:integer(Number.MAX_SAFE_INTEGER),updatedAt:integer(Number.MAX_SAFE_INTEGER),expiresAt:integer(Number.MAX_SAFE_INTEGER,1),revision:integer(Number.MAX_SAFE_INTEGER,1),attemptOf:{oneOf:[jobId,{type:'null'}]},contentPolicy:{enum:['metadata-only','host-redacted']},artifactCount:integer(JOB_LIMITS.artifacts),snapshotCount:integer(JOB_LIMITS.artifacts),resultAvailability:{enum:['none','source-references','snapshot-references','mixed-references','expired']}}); export const SCHEMAS = Object.freeze({ + ...Object.fromEntries(Object.entries(LANGUAGE_EDITING_SCHEMAS).map(([name, body]) => [name, define(name, body)])), ...Object.fromEntries(Object.entries(WORKSPACE_EDIT_SCHEMAS).map(([name, body]) => [name, define(name, body)])), ...Object.fromEntries(Object.entries(LANGUAGE_ASSISTANCE_SCHEMAS).map(([name, body]) => [name, define(name, body)])), ...Object.fromEntries(Object.entries({...PROJECT_WATCH_SCHEMAS,...PROJECT_QUERY_SCHEMAS,...WORKSPACE_PROJECT_SCHEMAS,...ARTIFACT_TRANSFER_SCHEMAS,...UPLOAD_SCHEMAS,...TRANSFER_QUEUE_SCHEMAS}).map(([name,body])=>[name,define(name,body)])), @@ -160,11 +166,20 @@ export const SCHEMAS = Object.freeze({ protocolVersion: {const: 1}, requestId: text(128), scope, snapshot: object(identity), diagnostics: list(diagnostic, LIMITS.maxDiagnostics), })), - 'language-request': define('language-request', {oneOf: LANGUAGE_FEATURES.map(kind => object({ - protocolVersion: {const: 1}, requestId: text(128), scope, - snapshot: object({...identity, text: {type: 'string', maxLength: LIMITS.documentBytes, 'x-maxUtf8Bytes': LIMITS.documentBytes}}), - kind: {const: kind}, ...(kind === 'document-symbols' ? {} : {position}), ...(kind === 'references' ? {includeDeclaration: {type: 'boolean'}} : {}), ...(['completion', 'signature-help'].includes(kind) ? {context: languageContextSchema(kind)} : {}), ...(kind === 'rename' ? {newName: text(256)} : {}), - }, ['protocolVersion', 'requestId', 'scope', 'snapshot', 'kind', ...(kind === 'document-symbols' ? [] : ['position']), ...(kind === 'rename' ? ['newName'] : [])]))}), + 'language-request': define('language-request', {oneOf: LANGUAGE_FEATURES.map(kind => { + const positional = !['document-symbols', 'format-document', 'format-range', 'code-actions'].includes(kind); + const formatting = ['format-document', 'format-range'].includes(kind), action = kind === 'code-actions'; + const fields = { + protocolVersion: {const: 1}, requestId: text(128), scope, + snapshot: object({...identity, text: {type: 'string', maxLength: LIMITS.documentBytes, 'x-maxUtf8Bytes': LIMITS.documentBytes}}), kind: {const: kind}, + ...(positional ? {position} : {}), ...(kind === 'references' ? {includeDeclaration: {type: 'boolean'}} : {}), + ...(['completion', 'signature-help'].includes(kind) ? {context: languageContextSchema(kind)} : {}), ...(kind === 'rename' ? {newName: text(256)} : {}), + ...(formatting || action ? {path: text(1024)} : {}), ...(kind === 'format-range' || action ? {range} : {}), + ...(formatting ? {formatOptions: FORMATTING_OPTIONS_SCHEMA} : {}), + ...(action ? {context: CODE_ACTION_CONTEXT_SCHEMA, diagnosticContext: object({revision: integer(Number.MAX_SAFE_INTEGER), diagnostics: list(diagnostic, LIMITS.maxDiagnostics)})} : {}), + }; + return object(fields, Object.keys(fields).filter(key => !['includeDeclaration', 'context'].includes(key))); + })}), 'language-result': define('language-result', {oneOf: LANGUAGE_FEATURES.map(kind => object({ protocolVersion: {const: 1}, requestId: text(128), scope, snapshot: object(identity), kind: {const: kind}, data: languageData[kind], }))}), diff --git a/tests/language-editing.test.mjs b/tests/language-editing.test.mjs new file mode 100644 index 0000000..e383e01 --- /dev/null +++ b/tests/language-editing.test.mjs @@ -0,0 +1,143 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createPluginHost, createLanguageResult, createDiagnosticsResult, definePlugin, parseManifest, parseLanguageRequest, parseCodeAction, parseFormattingOptions, LANGUAGE_LIMITS} from '../src/index.mjs'; +import {editHash} from '../src/workspace-edit-contracts.mjs'; +import {deferred, hasCode} from './fixtures.mjs'; + +const snapshot = {uri: 'memory:///main.dds', languageId: 'teaching', modelVersion: 1, workspaceRevision: 'one', text: 'x \r\nTODO 😀'}; +const range = {start: {line: 0, character: 0}, end: {line: 1, character: 7}}; +const diagnostic = {range: {start: {line: 1, character: 0}, end: {line: 1, character: 4}}, severity: 'warning', message: 'Replace TODO'}; +const trim = {range: {start: {line: 0, character: 1}, end: {line: 0, character: 3}}, text: ''}; +const formatOptions = {tabSize: 2, insertSpaces: true, trimTrailingWhitespace: true}; +const plan = (req, edits = [trim], path = req.path) => ({formatVersion: 1, id: crypto.randomUUID(), title: 'Proposed edit', changes: [{kind: 'edit', path, baseRevision: editHash(req.snapshot.text), edits}]}); +const action = {title: 'Replace TODO', kind: 'quickfix', isPreferred: true, diagnosticIndices: [0], resolveData: {fix: 'todo'}}; +const manifest = {manifestVersion: 2, id: 'editing', name: 'Editing', publisher: 'example', version: '1.0.0', protocolVersion: 1, entry: './plugin.mjs', runtime: 'ui', supportedHosts: ['test-host'], license: 'Apache-2.0', source: {visibility: 'open', licenseFile: 'LICENSE'}, capabilities: ['diagnostics', 'format-document', 'format-range', 'code-actions', 'code-action-resolve'], permissions: ['document.read', 'diagnostics.publish', 'language.provide']}; +async function fixture(t, {format = req => createLanguageResult(req, plan(req)), provide = req => createLanguageResult(req, [action]), resolve = (req, item) => ({...item, edit: plan(req, [{range: diagnostic.range, text: 'DONE'}])}), diagnostics = req => createDiagnosticsResult(req, [diagnostic]), capabilities = manifest.capabilities} = {}) { + const host = createPluginHost({grants: manifest.permissions}); t.after(() => host.dispose()); let diagnosticRegistration, actionRegistration; + await host.activate(definePlugin({...manifest, capabilities}, context => { + diagnosticRegistration = context.registerDiagnosticsProvider({languages: ['teaching']}, {provideDiagnostics: diagnostics}); + for (const kind of ['format-document', 'format-range']) context.registerLanguageProvider(kind, {languages: ['teaching']}, {provide: format}); + actionRegistration = context.registerLanguageProvider('code-actions', {languages: ['teaching']}, {provide, ...(resolve ? {resolve} : {})}); + })); + host.setDocument(snapshot); await host.requestDiagnostics(); + return {host, diagnosticRegistration, actionRegistration, request: () => host.requestLanguage('code-actions', {path: 'main.dds', range})}; +} + +test('formatting validates options, current file scope, range and CRLF; pure no-ops become null', async t => { + const {host} = await fixture(t); + const result = await host.requestLanguage('format-document', {path: 'main.dds', formatOptions}); + assert.equal(host.prepareFormatting(result).changes[0].edits[0].text, ''); + assert.throws(() => host.prepareFormatting(structuredClone(result)), hasCode('stale_snapshot')); + assert.equal((await host.requestLanguage('format-range', {path: 'main.dds', formatOptions, range: {start: range.start, end: {line: 0, character: 3}}})).data.changes.length, 1); + const noop = await fixture(t, {format: req => createLanguageResult(req, plan(req, [{...trim, text: ' '}]))}); + assert.equal((await noop.host.requestLanguage('format-document', {path: 'main.dds', formatOptions})).data, null); + assert.throws(() => parseFormattingOptions({...formatOptions, tabSize: 0}), hasCode('invalid_contract')); + assert.throws(() => parseFormattingOptions({...formatOptions, command: 'execute'}), hasCode('invalid_contract')); + const replacement = {range, text: 'x\nTODO 😀'}; + const lf = await fixture(t, {format: req => createLanguageResult(req, plan(req, [replacement]))}); + await assert.rejects(lf.host.requestLanguage('format-document', {path: 'main.dds', formatOptions}), hasCode('provider_failed')); + assert.ok((await lf.host.requestLanguage('format-document', {path: 'main.dds', formatOptions: {...formatOptions, endOfLine: 'lf'}})).data); + host.setDocument({...snapshot, modelVersion: 2, text: 'changed'}); + assert.throws(() => host.prepareFormatting(result), hasCode('stale_snapshot')); +}); + +test('formatters cannot edit outside the selected range, overlap edits, change paths or exceed file bounds', async t => { + for (const format of [ + req => createLanguageResult(req, plan(req, [trim, trim])), + req => createLanguageResult(req, plan(req, [{range: {start: {line: 1, character: 6}, end: {line: 1, character: 6}}, text: 'x'}])), + req => createLanguageResult(req, plan(req, [trim], 'other.dds')), + req => createLanguageResult(req, plan(req, [{...trim, text: 'x'.repeat(262_144)}])), + req => createLanguageResult(req, {...plan(req), changes: [{...plan(req).changes[0], baseRevision: '0'.repeat(64)}]}), + ]) { + const {host} = await fixture(t, {format}); + await assert.rejects(host.requestLanguage('format-document', {path: 'main.dds', formatOptions}), hasCode('provider_failed')); + } + const {host} = await fixture(t); + await assert.rejects(host.requestLanguage('format-range', {path: 'main.dds', formatOptions, range: diagnostic.range}), hasCode('provider_failed')); +}); + +test('actions bind the accepted diagnostic bundle and expose only explicit proposed edits', async t => { + let context; + const {host, request} = await fixture(t, {provide: req => {context = req.diagnosticContext; return createLanguageResult(req, [action]);}}); + const list = await request(); assert.deepEqual(context.diagnostics, [diagnostic]); assert.ok(context.revision > 0); + assert.equal(list.data[0].resolveData, undefined); assert.ok(list.data[0].resolveToken); + assert.throws(() => host.prepareCodeAction(list), hasCode('capability_unavailable')); + const resolved = await host.resolveCodeAction(list.data[0].resolveToken); + assert.equal(host.prepareCodeAction(resolved).changes[0].edits[0].text, 'DONE'); + await assert.rejects(host.resolveCodeAction(list.data[0].resolveToken), hasCode('stale_snapshot')); + assert.throws(() => host.prepareCodeAction(structuredClone(resolved)), hasCode('stale_snapshot')); + host.releaseCodeActions(resolved); assert.throws(() => host.prepareCodeAction(resolved), hasCode('stale_snapshot')); + const disabled = await fixture(t, {provide: req => createLanguageResult(req, [{title: '', kind: 'quickfix', disabled: {reason: 'No fix'}}])}); + const unavailable = await disabled.request(); assert.equal(unavailable.data[0].title, ''); + assert.throws(() => disabled.host.prepareCodeAction(unavailable), hasCode('capability_unavailable')); +}); + +test('updating diagnostics invalidates retained and in-flight actions without changing the document', async t => { + const finish = deferred(), started = deferred(); + const {host, request, diagnosticRegistration} = await fixture(t, {resolve: async (req, item) => {started.resolve(); await finish.promise; return {...item, edit: plan(req)};}}); + const old = await request(); const pending = host.resolveCodeAction(old.data[0].resolveToken), rejected = assert.rejects(pending, hasCode('stale_snapshot')); + await started.promise; await host.requestDiagnostics(); await rejected; finish.resolve(); + assert.throws(() => host.prepareCodeAction(old), hasCode('stale_snapshot')); + const newer = await request(); diagnosticRegistration.dispose(); + await assert.rejects(host.resolveCodeAction(newer.data[0].resolveToken), hasCode('stale_snapshot')); +}); + +test('out-of-order diagnostics do not replace the newer accepted action context', async t => { + let calls = 0; const older = deferred(), started = deferred(); let observed; + const {host, request} = await fixture(t, {diagnostics: async req => { + const index = ++calls; + if (index === 2) {started.resolve(); await older.promise;} + return createDiagnosticsResult(req, [{...diagnostic, message: 'publication-' + index}]); + }, provide: req => {observed = req.diagnosticContext; return createLanguageResult(req, [action]);}}); + const first = host.requestDiagnostics(); await started.promise; + await host.requestDiagnostics(); const current = await request(); assert.equal(observed.diagnostics[0].message, 'publication-3'); + older.resolve(); await first; + const result = await host.resolveCodeAction(current.data[0].resolveToken); assert.ok(result.data[0].edit); + await request(); assert.equal(observed.diagnostics[0].message, 'publication-3'); +}); + +test('action filters, immutable selection, diagnostic indices and executable fields are enforced', async t => { + for (const value of [{...action, command: 'shell'}, {...action, url: 'https://example.invalid'}, {...action, disabled: {reason: 'No'}}, {...action, diagnosticIndices: [0, 0]}]) assert.throws(() => parseCodeAction(value), hasCode('invalid_contract')); + const {host} = await fixture(t); + await assert.rejects(host.requestLanguage('code-actions', {path: 'main.dds', range, context: {triggerKind: 'invoked', only: ['refactor']}}), hasCode('provider_failed')); + const badIndex = await fixture(t, {provide: req => createLanguageResult(req, [{...action, diagnosticIndices: [1]}])}); + await assert.rejects(badIndex.request(), hasCode('provider_failed')); + const changed = await fixture(t, {resolve: (req, item) => ({...item, title: 'Different choice', edit: plan(req)})}); + await assert.rejects(changed.host.resolveCodeAction((await changed.request()).data[0].resolveToken), hasCode('invalid_contract')); + const foreign = await fixture(t); await assert.rejects(foreign.host.resolveCodeAction((await host.requestLanguage('code-actions', {path: 'main.dds', range})).data[0].resolveToken), hasCode('stale_snapshot')); +}); + +test('ignored action cancellation, provider disposal and document changes cannot revive old tokens', async t => { + const finish = deferred(); let calls = 0; + const {host, request, actionRegistration} = await fixture(t, {resolve: async (req, item) => {calls++; await finish.promise; return {...item, edit: plan(req)};}}); + try { + for (let n = 0; n < LANGUAGE_LIMITS.resolvePending; n++) await assert.rejects(host.resolveCodeAction((await request()).data[0].resolveToken, {timeoutMs: 5}), hasCode('budget_exceeded')); + await assert.rejects(host.resolveCodeAction((await request()).data[0].resolveToken), hasCode('budget_exceeded')); assert.equal(calls, 4); + } finally {finish.resolve();} + await new Promise(resolve => setImmediate(resolve)); + const result = await request(); actionRegistration.dispose(); + await assert.rejects(host.resolveCodeAction(result.data[0].resolveToken), hasCode('stale_snapshot')); + const changed = await fixture(t), retained = await changed.request(); changed.host.setDocument({...snapshot, modelVersion: 2, text: snapshot.text + '!'}); + await assert.rejects(changed.host.resolveCodeAction(retained.data[0].resolveToken), hasCode('stale_snapshot')); +}); + +test('action resolve capability and request-only diagnostics cannot be forged through host input', async t => { + assert.throws(() => parseManifest({...manifest, capabilities: ['code-action-resolve']}), hasCode('invalid_contract')); + await assert.rejects(fixture(t, {capabilities: manifest.capabilities.filter(cap => cap !== 'code-action-resolve')}), hasCode('permission_denied')); + const {host} = await fixture(t); + await assert.rejects(host.requestLanguage('code-actions', {path: 'main.dds', range, diagnosticContext: {revision: 0, diagnostics: []}}), hasCode('invalid_contract')); + const request = {protocolVersion: 1, requestId: 'test', scope: {projectId: 'p', sessionId: 's'}, snapshot, kind: 'format-document', path: 'main.dds', formatOptions}; + assert.throws(() => parseLanguageRequest({...request, range}), hasCode('invalid_contract')); + assert.throws(() => parseLanguageRequest({...request, context: {triggerKind: 'invoked'}}), hasCode('invalid_contract')); +}); + +test('action counts, private resolve data and provider-supplied tokens obey their budgets', async t => { + assert.throws(() => parseCodeAction({...action, resolveData: 'x'.repeat(4096)}), hasCode('budget_exceeded')); + const oversized = await fixture(t, {provide: req => createLanguageResult(req, Array.from({length: 101}, () => action))}); + await assert.rejects(oversized.request(), hasCode('provider_failed')); + const forged = await fixture(t, {provide: req => createLanguageResult(req, [{...action, resolveToken: crypto.randomUUID()}])}); + await assert.rejects(forged.request(), hasCode('invalid_contract')); + const bounded = await fixture(t, {provide: req => createLanguageResult(req, Array.from({length: 100}, () => action))}); + const list = await bounded.request(); await assert.rejects(bounded.request(), hasCode('budget_exceeded')); + bounded.host.releaseCodeActions(list); assert.equal((await bounded.request()).data.length, 100); +}); diff --git a/tests/types/language-editing.mts b/tests/types/language-editing.mts new file mode 100644 index 0000000..30211d1 --- /dev/null +++ b/tests/types/language-editing.mts @@ -0,0 +1,27 @@ +import {createPluginHost, createLanguageRegistry, createLanguageResult, parseCodeAction, parseFormattingOptions, type CodeAction, type FormattingOptions} from '@altifigence/dds-plugin-sdk'; +import type {WorkspaceEdit} from '@altifigence/dds-plugin-sdk/workspace-edits'; +const host = createPluginHost(); +const options: FormattingOptions = parseFormattingOptions({tabSize: 2, insertSpaces: true}); +const range = {start: {line: 0, character: 0}, end: {line: 0, character: 4}}; +const formatted = await host.requestLanguage('format-document', {path: 'main.txt', formatOptions: options}); +const edit: WorkspaceEdit | null = host.prepareFormatting(formatted); +await host.requestLanguage('format-range', {path: 'main.txt', range, formatOptions: options}); +const actions = await host.requestLanguage('code-actions', {path: 'main.txt', range, context: {triggerKind: 'invoked', only: ['quickfix']}}); +const resolved = await host.resolveCodeAction(actions.data[0]!.resolveToken!); +const fix: WorkspaceEdit = host.prepareCodeAction(resolved); host.releaseCodeActions(actions); +const registry = createLanguageRegistry('code-actions'); +registry.register('typed', {languages: ['plain']}, { + provide(request) {const diagnosticRevision: number = request.diagnosticContext.revision; void diagnosticRevision; return createLanguageResult(request, [{title: 'Fix', kind: 'quickfix', resolveData: {id: 1}}]);}, + resolve(_request, selected) {const action: CodeAction = selected; return {...action, edit: fix};}, +}, {resolve: true}); +registry.release(await registry.resolve('host-issued-token')); +parseCodeAction({title: 'Unavailable', kind: 'quickfix', disabled: {reason: 'No fix'}}); +// @ts-expect-error Document formatting has no position. +host.requestLanguage('format-document', {path: 'main.txt', formatOptions: options, position: {line: 0, character: 0}}); +// @ts-expect-error Range formatting requires a range. +host.requestLanguage('format-range', {path: 'main.txt', formatOptions: options}); +// @ts-expect-error Only the host injects the diagnostic bundle. +host.requestLanguage('code-actions', {path: 'main.txt', range, diagnosticContext: {revision: 0, diagnostics: []}}); +// @ts-expect-error Executable action kinds are unsupported. +const bad: CodeAction = {title: 'Run shell', kind: 'shell', resolveData: {}}; +void edit; void bad; From 112eb58ac17ac0bc100319305d0c8138050c2674 Mon Sep 17 00:00:00 2001 From: hkimw <54717101+hkimw@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:16:18 +0900 Subject: [PATCH 4/4] feat: add bounded semantic display and complete 0.11 language APIs --- CHANGELOG.md | 7 +- PUBLIC_SURFACE.json | 15 + README.md | 8 +- docs/COMPATIBILITY.md | 7 +- docs/LANGUAGE.md | 5 +- docs/LANGUAGE_DISPLAY.md | 101 ++++ docs/api.md | 22 +- examples/language-display/LICENSE | 13 + examples/language-display/browser.html | 11 + examples/language-display/browser.mjs | 60 +++ examples/language-display/plugin.mjs | 55 +++ examples/language-display/run.mjs | 21 + examples/language-display/serve.mjs | 18 + package.json | 4 +- schemas/document-symbol-tree.schema.json | 158 +++++++ schemas/folding-ranges.schema.json | 80 ++++ schemas/inlay-hints.schema.json | 61 +++ schemas/language-capabilities.schema.json | 69 ++- schemas/language-request.schema.json | 393 ++++++++++++++++ schemas/language-result.schema.json | 546 ++++++++++++++++++++++ schemas/manifest.schema.json | 29 +- schemas/semantic-legend.schema.json | 60 +++ schemas/semantic-tokens-delta.schema.json | 62 +++ schemas/semantic-tokens.schema.json | 95 ++++ scripts/pack-check.mjs | 3 + src/contracts.mjs | 21 +- src/host.mjs | 19 +- src/index.d.mts | 49 +- src/index.mjs | 1 + src/language-assistance-schemas.mjs | 3 +- src/language-display-schemas.mjs | 16 + src/language-display.mjs | 140 ++++++ src/language-registry.mjs | 89 +++- src/schemas.mjs | 12 +- tests/language-display.test.mjs | 173 +++++++ tests/types/language-display.mts | 29 ++ 36 files changed, 2414 insertions(+), 41 deletions(-) create mode 100644 docs/LANGUAGE_DISPLAY.md create mode 100644 examples/language-display/LICENSE create mode 100644 examples/language-display/browser.html create mode 100644 examples/language-display/browser.mjs create mode 100644 examples/language-display/plugin.mjs create mode 100644 examples/language-display/run.mjs create mode 100644 examples/language-display/serve.mjs create mode 100644 schemas/document-symbol-tree.schema.json create mode 100644 schemas/folding-ranges.schema.json create mode 100644 schemas/inlay-hints.schema.json create mode 100644 schemas/semantic-legend.schema.json create mode 100644 schemas/semantic-tokens-delta.schema.json create mode 100644 schemas/semantic-tokens.schema.json create mode 100644 src/language-display-schemas.mjs create mode 100644 src/language-display.mjs create mode 100644 tests/language-display.test.mjs create mode 100644 tests/types/language-display.mts diff --git a/CHANGELOG.md b/CHANGELOG.md index f354bd2..955fd36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog -## 0.11.0 — Unreleased +## 0.11.0 — 2026-10-04 + +- Add semantic full/delta tokens with bounded provider-bound caches and one full + fallback, folding ranges, literal inlay hints and hierarchical document symbols. + Validate actual host-owned results immediately before display. Add independent + Node and browser examples, public schemas and installed-package type checks. - Add document/range formatting and diagnostic-bound code action lists/resolution. Validate selection scope, newline policy and current snapshots; route proposed diff --git a/PUBLIC_SURFACE.json b/PUBLIC_SURFACE.json index 1a28728..6198fd8 100644 --- a/PUBLIC_SURFACE.json +++ b/PUBLIC_SURFACE.json @@ -18,6 +18,7 @@ "docs/JOB_HISTORY.md", "docs/JOB_STORAGE.md", "docs/JOBS.md", + "docs/LANGUAGE_DISPLAY.md", "docs/LANGUAGE.md", "docs/LANGUAGE_ASSISTANCE.md", "docs/LANGUAGE_EDITING.md", @@ -66,6 +67,12 @@ "examples/language-assistance/LICENSE", "examples/language-assistance/plugin.mjs", "examples/language-assistance/run.mjs", + "examples/language-display/browser.html", + "examples/language-display/browser.mjs", + "examples/language-display/LICENSE", + "examples/language-display/plugin.mjs", + "examples/language-display/run.mjs", + "examples/language-display/serve.mjs", "examples/language-editing/LICENSE", "examples/language-editing/plugin.mjs", "examples/language-editing/run.mjs", @@ -124,10 +131,13 @@ "schemas/command.schema.json", "schemas/completion-item.schema.json", "schemas/code-action.schema.json", + "schemas/document-symbol-tree.schema.json", + "schemas/folding-ranges.schema.json", "schemas/formatting-options.schema.json", "schemas/diagnostics-request.schema.json", "schemas/diagnostics-result.schema.json", "schemas/error.schema.json", + "schemas/inlay-hints.schema.json", "schemas/job-artifact-content.schema.json", "schemas/job-capabilities.schema.json", "schemas/job-events.schema.json", @@ -152,6 +162,9 @@ "schemas/project-watch-capabilities.schema.json", "schemas/project-watch-event.schema.json", "schemas/project-watch-options.schema.json", + "schemas/semantic-legend.schema.json", + "schemas/semantic-tokens-delta.schema.json", + "schemas/semantic-tokens.schema.json", "schemas/signature-help.schema.json", "schemas/stored-artifact-chunk.schema.json", "schemas/stored-artifact-list.schema.json", @@ -217,6 +230,8 @@ "src/jobs.mjs", "src/language-assistance-schemas.mjs", "src/language-assistance.mjs", + "src/language-display-schemas.mjs", + "src/language-display.mjs", "src/language-editing.mjs", "src/language-editing-schemas.mjs", "src/language-registry.mjs", diff --git a/README.md b/README.md index 77b0813..76b0e31 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,15 @@ a small ESM package, TypeScript declarations and versioned contracts. Connect your own workspace and tools with the Node.js host and HTTP client. The SDK has **zero runtime dependencies**. -Development toward 0.11 adds [signature help and completion assistance](docs/LANGUAGE_ASSISTANCE.md), +Version 0.11 adds [signature help and completion assistance](docs/LANGUAGE_ASSISTANCE.md), including lazy item resolution, limited snippets and pure insertion previews. Run `npm run example:assistance` for the nested-call and Unicode example. [Reviewed workspace edits](docs/WORKSPACE_EDITS.md) add rename proposals, multi-file previews, conditional application and readback after interrupted operations. [Formatting and code actions](docs/LANGUAGE_EDITING.md) use that review path for document/range formatting and diagnostic-bound, optionally lazy quick fixes. +[Language display](docs/LANGUAGE_DISPLAY.md) adds bounded semantic full/delta +tokens, folding, literal inlay hints and hierarchical symbols with a browser example. The 0.10 line adds [browser artifact streaming](docs/BROWSER_ARTIFACTS.md) with bounded sinks, incremental whole-file hashes, explicit storage readback and @@ -32,7 +34,7 @@ application, private engines/services and production signing systems are outside this repository. Read the [public scope](docs/PUBLIC_SCOPE.md) and [compatibility table](docs/COMPATIBILITY.md) for exact boundaries. -Version **0.10.0** supports Node 22 and 24. Manifest v1 diagnostic plugins remain +Version **0.11.0** supports Node 22 and 24. Manifest v1 diagnostic plugins remain compatible. Manifest v2 adds commands, permission-scoped workspace and backend APIs, and open-source or proprietary distribution metadata. Themes export to the XML format already supported by DDS. The workspace host runs trusted plugins @@ -92,7 +94,7 @@ mkdir my-dds-plugin cd my-dds-plugin npm init -y npm pkg set type=module -npm install --ignore-scripts --save-exact https://github.com/Altifigence/dds-plugin-sdk/releases/download/v0.10.0/altifigence-dds-plugin-sdk-0.10.0.tgz +npm install --ignore-scripts --save-exact https://github.com/Altifigence/dds-plugin-sdk/releases/download/v0.11.0/altifigence-dds-plugin-sdk-0.11.0.tgz ``` Copy the example's `plugin.mjs` and `run.mjs` into this directory, then run: diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 7c3b0b7..51a3d66 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -1,6 +1,6 @@ # Compatibility and availability -## 0.11 development +## 0.11 language assistance and editing Optional `signature-help`, `completion-resolve` and `completion-snippets` extend the in-process host. Existing five language features and literal completion remain @@ -14,6 +14,9 @@ journal. The Node journal is optional, operator-owned storage outside the worksp [Formatting and code actions](LANGUAGE_EDITING.md) are opt-in language proposals; accepted diagnostic publications invalidate action selections. Existing diagnostic call results and single-file edit sessions retain their contracts. +[Semantic tokens, folding, hints and hierarchical symbols](LANGUAGE_DISPLAY.md) +extend SDK hosts and include a standalone browser example. The flat symbol API +is unchanged. These features do not establish a DDS product editor integration. | Surface | Supported scope | | --- | --- | @@ -26,7 +29,7 @@ call results and single-file edit sessions retain their contracts. | Browser-only DDS host | Native workspace transport is unavailable | | Language providers | Diagnostics plus completion, hover, definition, references and document symbols in SDK hosts since 0.3.0; no released DDS editor bridge is implied | | Project edit sessions | Client helpers in 0.3.0 over unchanged workspace protocol v1, including 0.2.x servers | -| Download verification | Node API/CLI since 0.3.1; 0.10.0 accepts exact 0.3.x–0.10.x package metadata; no extraction, code execution or publisher authentication | +| Download verification | Node API/CLI since 0.3.1; 0.11.0 accepts exact 0.3.x–0.11.x package metadata; no extraction, code execution or publisher authentication | | Development tools | `init`, `doctor`, `dev` since 0.4.0; local Node tools, explicit trusted-code execution | | Command jobs | Opt-in SDK host/client extension since 0.4.0; progress/logs/UTF-8 artifacts; v1 hello unchanged | | File and job observation | Client helpers since 0.5.0; 0.6 uses revision reads when supported; job reads need enabled 0.4+ hosts | diff --git a/docs/LANGUAGE.md b/docs/LANGUAGE.md index ae1a290..558f4ba 100644 --- a/docs/LANGUAGE.md +++ b/docs/LANGUAGE.md @@ -5,6 +5,8 @@ Run `node examples/hello-language/run.mjs` after installing the SDK. The example implements a tiny teaching language; it is not a SystemVerilog compiler or LSP server. SDK 0.11 adds [signature help, lazy completion resolution and opt-in snippets](LANGUAGE_ASSISTANCE.md). +It also adds [reviewed rename edits](WORKSPACE_EDITS.md), [formatting and code actions](LANGUAGE_EDITING.md), +and [semantic tokens, folding, hints and hierarchical symbols](LANGUAGE_DISPLAY.md). The original literal contracts below continue to work without these capabilities. Declare each feature in manifest v2 `capabilities`, and declare `document.read` @@ -60,7 +62,8 @@ additional 16,384-character limits. Type declarations and JSON Schemas accompany runtime validation; the runtime additionally checks UTF-8 budgets and document ranges. Provider selection uses the highest selector priority, then earliest registration. -One provider answers each request. Document symbols are flat; this version does +One provider answers each request. `document-symbols` remains flat; the separate +`document-symbol-tree` feature returns a validated hierarchy. This version does not merge providers, auto-apply edits, execute completion commands or start LSP processes. These APIs run in the SDK's trusted in-process host. They do not add editor UI diff --git a/docs/LANGUAGE_DISPLAY.md b/docs/LANGUAGE_DISPLAY.md new file mode 100644 index 0000000..87fbafd --- /dev/null +++ b/docs/LANGUAGE_DISPLAY.md @@ -0,0 +1,101 @@ +# Semantic tokens, folding, hints and symbol trees + +SDK 0.11 adds four optional features to the trusted in-process host. Declare each +feature in manifest v2, with `document.read` and `language.provide` permissions and +matching host grants. This guide describes SDK APIs and the public teaching +example. It does not establish a released DDS editor bridge or an LSP connection. + +| Feature | Request input | Result data | +| --- | --- | --- | +| `semantic-tokens` | `{previousResultId?}` | Full normalized `{resultId, legend, data, updateKind}` | +| `folding-ranges` | `{}` | Ordered `{range, kind?, collapsedText?}` array | +| `inlay-hints` | `{range}` | Ordered `{position, label, kind?, paddingLeft?, paddingRight?, tooltip?}` array | +| `document-symbol-tree` | `{}` | Hierarchical document symbols with optional `children` | + +The older `document-symbols` feature keeps its flat shape and 500-item limit. +All coordinates use zero-based lines and UTF-16 code units. Runtime validation +rejects positions outside the requested document and positions splitting a +surrogate pair. Labels and tooltips are plain text; they cannot contain commands, +links with executable behavior or arbitrary presentation fields. + +## Semantic data and incremental updates + +The legend has `tokenTypes: [{name, style}]` and `tokenModifiers: [name]`. Type +names are unique ASCII identifiers; style is one of `SEMANTIC_STYLE_KEYS`: +`plain`, `keyword`, `type`, `function`, `variable`, `number`, `string`, `comment`, +`operator`. Map only these keys to host-owned styles. A modifier name is data; +apply only modifier styling that the host explicitly recognizes. + +Each token uses five integers: delta line, delta start character, positive +length, legend type index and modifier bitset. A nonzero line delta makes the +start character relative to the new line. Tokens are ordered, nonoverlapping and +confined to one line. `decodeSemanticTokens(data, documentText)` validates and +returns explicit ranges and style names. Limits are 20,000 tokens, 64 types and +16 modifiers, within the 1.6 MB result envelope. + +An ordinary provider returns `createLanguageResult(request, {resultId, legend, +data})` from `provide`. Its result ID is private to that provider. The host replaces +it with a secure UUID handle and always returns a full array to the UI, with +`updateKind: 'full' | 'delta' | 'fallback'`. + +To enable deltas, also declare `semantic-tokens-delta` and implement +`provideDelta(request, previous, {signal})`. `previous` contains the provider's +result ID, old snapshot identity, legend and integer array; it excludes old +document text and the host UUID. Return `null` if a full recomputation is needed, +or `{baseResultId, resultId, edits: [{start, deleteCount, data?}]}`. Edits address +positions in the original integer array, not token or character positions. Up to +64 edits must be ordered and disjoint; insertion points cannot repeat. The host +validates the whole reconstructed array against the new document. + +Pass a host handle back as `previousResultId` after a document edit. The cache +retains at most eight results and 4 MiB, with a 60-second lifetime and LRU eviction. +It binds provider, project/session, URI and language. Ordinary monotonic model +updates preserve the base while invalidating the old display result. Document +replacement, nonmonotonic version reset, unregister, deactivate and disposal +clear affected bases. A provider change cannot consume another provider's base. + +An unknown, expired or evicted handle, absent delta capability, `null` delta, or +different provider base ID triggers at most one full request. Both operations +share the caller's cancellation and original time budget. Invalid delta data, +provider exceptions and cancellation fail the request; they do not trigger a +retry. Ignored cancellation retains the real operation's concurrency slot until +the provider settles. + +## Rendering and release + +Before painting, call `host.validateLanguageResult(result)`. It accepts only the +actual result owned by that host, with a current snapshot and active provider; +a copied, released or stale result fails. `host.releaseLanguageResult(result)` +releases its semantic base and display ownership. Using an old handle as a delta +base never makes that old result valid for display. + +Folding accepts 1,000 nonempty ranges, ordered outer-first, with nesting depth at +most 32. Crossing and duplicate ranges are invalid. Inlay hints accept 1,000 +items within the requested range; labels are single-line, up to 1,024 UTF-16 +units, and tooltips up to 4,096. Symbol trees contain at most 1,000 total symbols +and 16 levels. Children and selections must be inside their parent range; +siblings are ordered and nonoverlapping. Result ownership checks apply to all +four features, and existing language features can use the same validation API. + +Run the independent installed-package example: + +```sh +npm run example:language-display +npm run example:language-display-browser +``` + +Open the loopback URL printed by the second command. The example uses text nodes +and an explicit style whitelist, with no `innerHTML`. Exercise full refresh, +edit/delta, discarded-base fallback, folding, symbol navigation, deactivate and +restart. One hint intentionally displays `` literally. +The example implements a teaching grammar and uses no engine or external service. + +## 한국어 + +0.11의 의미 토큰·접기·인레이 힌트·계층형 심볼은 공개 SDK 호스트의 선택 기능입니다. +각 capability와 문서 읽기·언어 제공 권한을 선언하고 호스트에서 승인해야 합니다. +화면에 표시하기 직전 `validateLanguageResult`로 현재 문서와 제공자 소유권을 확인합니다. +문서 변경 후 이전 강조 결과는 표시할 수 없지만, 제한된 캐시가 남아 있으면 증분 계산의 +기준으로 사용할 수 있습니다. 기준을 잃으면 남은 시간 내 전체 요청을 한 번 수행합니다. +라벨은 일반 텍스트이며 접기 범위·심볼 포함 관계·UTF-16 좌표를 검증합니다. +DDS 제품 에디터 연결과 실제 언어 서버 지원은 별도 통합 및 검증 범위입니다. diff --git a/docs/api.md b/docs/api.md index 4bf24f5..abfca8a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,4 +1,4 @@ -# API reference — 0.10.0 +# API reference — 0.11.0 SDK 0.8 adds optional [job storage](JOB_STORAGE.md), [history and retry](JOB_HISTORY.md) and [stored result files](ARTIFACT_STORAGE.md). Existing v1 response shapes remain valid. @@ -80,7 +80,7 @@ license eligibility or agreement acceptance. See [LICENSING](LICENSING.md). | Activation context member | Behavior | | --- | --- | -| `host` | `{id: 'test-host' \| 'workspace-host', version: '0.10.0', protocolVersion: 1}` | +| `host` | `{id: 'test-host' \| 'workspace-host', version: '0.11.0', protocolVersion: 1}` | | `pluginId`, `scope` | Manifest ID and opaque host `{projectId, sessionId}` | | `grants` | Frozen intersection of host grants and manifest permissions | | `signal` | Aborted on deactivation, host disposal or activation timeout | @@ -311,3 +311,21 @@ explicit `File`. The HTTP client adds capability/begin/query/write/commit/abort methods. `/transfer-queue` exports `createTransferQueue`, options/snapshot validators, limits, states and phases. See [uploads](UPLOADS.md), [streaming](BROWSER_ARTIFACTS.md), [queue](TRANSFER_QUEUE.md) and [persistent recovery](BROWSER_RESUME.md). + +## Language assistance and editing (SDK 0.11.0) + +The root import adds signature help, completion resolution/snippets, rename, +formatting, code actions, semantic tokens, folding, inlay hints and symbol trees. +`host.languageCapabilities()` describes current features and limits; +`host.validateLanguageResult(result)` checks actual host ownership and currency +before painting. Features and modifiers require explicit manifest declarations. + +`/workspace-edits` supplies proposal/preview/receipt contracts, pure preview, +reviewed application, observational recovery and explicit inverse proposals. +`/workspace-edits-node` adds an operator-owned intent/receipt journal outside the +workspace. Application uses existing conditional file methods and current +authorization, with no automatic rollback or retry. + +See [assistance](LANGUAGE_ASSISTANCE.md), [workspace edits](WORKSPACE_EDITS.md), +[formatting/actions](LANGUAGE_EDITING.md) and [language display](LANGUAGE_DISPLAY.md) +for complete contracts, limits and independent examples. diff --git a/examples/language-display/LICENSE b/examples/language-display/LICENSE new file mode 100644 index 0000000..5abeb19 --- /dev/null +++ b/examples/language-display/LICENSE @@ -0,0 +1,13 @@ +Copyright 2026 Altifigence + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/examples/language-display/browser.html b/examples/language-display/browser.html new file mode 100644 index 0000000..774c44c --- /dev/null +++ b/examples/language-display/browser.html @@ -0,0 +1,11 @@ + +SDK language display + +Digital Design Studio · Public SDK example

Language display

A small teaching language demonstrates semantic colors, folding, inline type hints and a symbol tree. Edit changes one number and requests an incremental token update.

+
+

Loading…

+

counter.teaching

+

The second hint deliberately contains HTML-like text. The host renders it literally. This example uses a local in-process host and a teaching parser.

+ diff --git a/examples/language-display/browser.mjs b/examples/language-display/browser.mjs new file mode 100644 index 0000000..2c08329 --- /dev/null +++ b/examples/language-display/browser.mjs @@ -0,0 +1,60 @@ +import {createPluginHost, decodeSemanticTokens, SEMANTIC_STYLE_KEYS} from '@altifigence/dds-plugin-sdk'; +import {createTeachingDisplayPlugin, sample} from './plugin.mjs'; +const byId = id => document.getElementById(id), counters = {full: 0, delta: 0}; +const host = createPluginHost({grants: ['document.read', 'language.provide']}), plugin = createTeachingDisplayPlugin(counters); +let activation = await host.activate(plugin), semantic, stale = 0, active = true; +let model = {uri: 'memory:///counter', languageId: 'teaching', modelVersion: 1, workspaceRevision: 'one', text: sample}; +host.setDocument(model); +function status(message) {byId('status').textContent = message; byId('metrics').textContent = `Version ${model.modelVersion} · Full calls ${counters.full} · Delta calls ${counters.delta} · Stale results discarded ${stale}`;} +function paint(results) { + for (const result of results) host.validateLanguageResult(result); + const [tokens, folds, hints, symbols] = results, decoded = decodeSemanticTokens(tokens.data, model.text), editor = byId('editor'); + editor.replaceChildren(); byId('outline').replaceChildren(); + const rows = model.text.split(/\r\n|\r|\n/).map((text, line) => { + const row = document.createElement('div'); row.className = 'line'; row.tabIndex = -1; row.dataset.line = String(line); + const number = document.createElement('span'); number.className = 'number'; number.textContent = String(line + 1); row.append(number); + const fold = folds.data.find(item => item.range.start.line === line), gutter = document.createElement(fold ? 'button' : 'span'); + gutter.className = fold ? 'fold' : 'gutter'; + if (fold) { + gutter.textContent = '▾'; gutter.setAttribute('aria-label', `Fold line ${line + 1}`); gutter.setAttribute('aria-expanded', 'true'); + gutter.addEventListener('click', () => {const open = gutter.getAttribute('aria-expanded') === 'true'; gutter.setAttribute('aria-expanded', String(!open)); gutter.textContent = open ? '▸' : '▾'; for (let n = line + 1; n < fold.range.end.line; n++) rows[n].hidden = open;}); + } + row.append(gutter); let offset = 0; + const lineHints = hints.data.filter(hint => hint.position.line === line), boundaries = new Set([0, text.length]); + const lineTokens = decoded.filter(token => token.range.start.line === line); + for (const token of lineTokens) {boundaries.add(token.range.start.character); boundaries.add(token.range.end.character);} + for (const hint of lineHints) boundaries.add(hint.position.character); + for (const boundary of [...boundaries].sort((a, b) => a - b)) { + if (boundary > offset) { + const span = document.createElement('span'), token = lineTokens.find(token => token.range.start.character <= offset && token.range.end.character >= boundary); + const style = token && SEMANTIC_STYLE_KEYS.includes(token.style) ? token.style : 'plain'; span.className = style === 'number' ? 'number-token' : style; + span.textContent = text.slice(offset, boundary); row.append(span); + } + for (const hint of lineHints.filter(hint => hint.position.character === boundary)) {const span = document.createElement('span'); span.className = 'hint'; span.textContent = hint.label; span.title = hint.tooltip ?? ''; row.append(span);} + offset = boundary; + } + editor.append(row); return row; + }); + function tree(items, parent) { + const list = document.createElement('ul'); parent.append(list); + for (const item of items) {const row = document.createElement('li'), button = document.createElement('button'); button.textContent = `${item.name} · ${item.kind}`; button.addEventListener('click', () => {const target = rows[item.selectionRange.start.line]; target.hidden = false; target.focus();}); row.append(button); list.append(row); if (item.children?.length) tree(item.children, row);} + } + tree(symbols.data, byId('outline')); semantic = tokens; +} +async function refresh(previousResultId) { + const lines = model.text.split(/\r\n|\r|\n/), end = {line: lines.length - 1, character: lines.at(-1).length}; + const results = await Promise.all([host.requestLanguage('semantic-tokens', previousResultId ? {previousResultId} : {}), host.requestLanguage('folding-ranges', {}), host.requestLanguage('inlay-hints', {range: {start: {line: 0, character: 0}, end}}), host.requestLanguage('document-symbol-tree', {})]); + paint(results); status(`Rendered ${semantic.data.updateKind} result. Provider active.`); +} +function discardPrevious() {if (!semantic) return; try {host.validateLanguageResult(semantic);} catch (error) {if (!['stale_snapshot', 'disposed'].includes(error.code)) throw error; stale++;}} +async function perform(action) { + for (const button of document.querySelectorAll('.toolbar button')) button.disabled = true; + try {await action();} catch (error) {status(`Request stopped: ${error.code ?? 'unavailable'}`);} finally {for (const button of document.querySelectorAll('.toolbar button')) button.disabled = false; byId('deactivate').disabled = !active; byId('restart').disabled = active;} +} +byId('full').addEventListener('click', () => perform(() => refresh())); +byId('edit').addEventListener('click', () => perform(async () => {const previous = semantic?.data.resultId; model = {...model, modelVersion: model.modelVersion + 1, text: model.text.includes('12345') ? model.text.replace('12345', '12') : model.text.replace('12', '12345')}; host.setDocument(model); discardPrevious(); await refresh(previous);})); +byId('fallback').addEventListener('click', () => perform(async () => {const previous = semantic?.data.resultId; if (semantic) host.releaseLanguageResult(semantic); await refresh(previous);})); +byId('deactivate').addEventListener('click', () => perform(() => {activation.dispose(); active = false; discardPrevious(); byId('editor').replaceChildren(); byId('outline').replaceChildren(); status('Provider deactivated. Display results discarded.');})); +byId('restart').addEventListener('click', () => perform(async () => {activation = await host.activate(plugin); active = true; await refresh(semantic?.data.resultId);})); +window.addEventListener('pagehide', () => host.dispose(), {once: true}); +await perform(() => refresh()); diff --git a/examples/language-display/plugin.mjs b/examples/language-display/plugin.mjs new file mode 100644 index 0000000..e98eb34 --- /dev/null +++ b/examples/language-display/plugin.mjs @@ -0,0 +1,55 @@ +import {definePlugin, createLanguageResult} from '@altifigence/dds-plugin-sdk'; + +export const sample = 'module counter {\n let count = 12;\n let title = "😀";\n}\n'; +export const literalHint = ''; +const legend = {tokenTypes: [{name: 'keyword', style: 'keyword'}, {name: 'identifier', style: 'variable'}, {name: 'number', style: 'number'}, {name: 'string', style: 'string'}], tokenModifiers: []}; +const range = (line, start, end) => ({start: {line, character: start}, end: {line, character: end}}); +const compare = (a, b) => a.line - b.line || a.character - b.character; +// A deliberately small teaching grammar. Real languages own their parser and licensing. +function analyze(text) { + const lines = text.split(/\r\n|\r|\n/), data = [], hints = [], children = []; + let previousLine = 0, previousStart = 0; + for (const [line, content] of lines.entries()) { + for (const match of content.matchAll(/"[^"\r\n]*"|\b(?:module|let)\b|\b\d+\b|[A-Za-z_]\w*/g)) { + const type = ['module', 'let'].includes(match[0]) ? 0 : match[0].startsWith('"') ? 3 : /^\d/.test(match[0]) ? 2 : 1; + data.push(line - previousLine, line === previousLine ? match.index - previousStart : match.index, match[0].length, type, 0); + previousLine = line; previousStart = match.index; + } + const declaration = /\blet\s+([A-Za-z_]\w*)\s*=\s*(.+);/.exec(content); + if (declaration) { + const start = content.indexOf(declaration[1], declaration.index + 3), end = start + declaration[1].length; + children.push({name: declaration[1], kind: 'variable', range: range(line, declaration.index, declaration.index + declaration[0].length), selectionRange: range(line, start, end)}); + hints.push({position: {line, character: end}, label: /^\d/.test(declaration[2]) ? ': number' : literalHint, kind: 'type', paddingLeft: true, tooltip: 'Literal teaching hint. No HTML or command is evaluated.'}); + } + } + const opening = lines.findIndex(line => /^module\s+\w+\s*\{/.test(line)), closing = lines.findLastIndex(line => /^\}/.test(line)); + const folds = [], symbols = []; + if (opening >= 0 && closing > opening) { + const module = /^module\s+(\w+)/.exec(lines[opening]), start = lines[opening].indexOf(module[1]); + const whole = {start: {line: opening, character: 0}, end: {line: closing, character: 1}}; + folds.push({range: whole, kind: 'region', collapsedText: 'module ' + module[1] + ' …'}); + symbols.push({name: module[1], kind: 'module', range: whole, selectionRange: range(opening, start, start + module[1].length), children: children.filter(child => child.range.start.line > opening && child.range.end.line < closing)}); + } + return {data, hints, folds, symbols}; +} + +export function createTeachingDisplayPlugin(counters = {full: 0, delta: 0}) { + let sequence = 0; + return definePlugin({manifestVersion: 2, id: 'teaching-display', name: 'Teaching Display', publisher: 'example', version: '1.0.0', protocolVersion: 1, entry: './plugin.mjs', runtime: 'ui', license: 'Apache-2.0', source: {visibility: 'open', licenseFile: 'LICENSE'}, supportedHosts: ['test-host'], capabilities: ['semantic-tokens', 'semantic-tokens-delta', 'folding-ranges', 'inlay-hints', 'document-symbol-tree'], permissions: ['document.read', 'language.provide']}, context => { + context.registerLanguageProvider('semantic-tokens', {languages: ['teaching']}, { + provide(request, {signal}) {signal.throwIfAborted(); counters.full++; return createLanguageResult(request, {resultId: `tokens-${++sequence}`, legend, data: analyze(request.snapshot.text).data});}, + provideDelta(request, previous, {signal}) { + signal.throwIfAborted(); counters.delta++; + const next = analyze(request.snapshot.text).data; let start = 0, oldEnd = previous.data.length, newEnd = next.length; + while (start < oldEnd && start < newEnd && previous.data[start] === next[start]) start++; + while (oldEnd > start && newEnd > start && previous.data[oldEnd - 1] === next[newEnd - 1]) {oldEnd--; newEnd--;} + return {baseResultId: previous.resultId, resultId: `tokens-${++sequence}`, edits: start === oldEnd && start === newEnd ? [] : [{start, deleteCount: oldEnd - start, data: next.slice(start, newEnd)}]}; + }, + }); + for (const [kind, key] of [['folding-ranges', 'folds'], ['inlay-hints', 'hints'], ['document-symbol-tree', 'symbols']]) context.registerLanguageProvider(kind, {languages: ['teaching']}, {provide(request, {signal}) { + signal.throwIfAborted(); let values = analyze(request.snapshot.text)[key]; + if (kind === 'inlay-hints') values = values.filter(hint => compare(hint.position, request.range.start) >= 0 && compare(hint.position, request.range.end) <= 0); + return createLanguageResult(request, values); + }}); + }); +} diff --git a/examples/language-display/run.mjs b/examples/language-display/run.mjs new file mode 100644 index 0000000..c4ce12e --- /dev/null +++ b/examples/language-display/run.mjs @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import {createPluginHost, decodeSemanticTokens} from '@altifigence/dds-plugin-sdk'; +import {createTeachingDisplayPlugin, sample, literalHint} from './plugin.mjs'; +const host = createPluginHost({grants: ['document.read', 'language.provide']}), counters = {full: 0, delta: 0}; +try { + const plugin = createTeachingDisplayPlugin(counters), activation = await host.activate(plugin); + const document = {uri: 'memory:///counter', languageId: 'teaching', modelVersion: 1, workspaceRevision: 'one', text: sample}; host.setDocument(document); + const full = await host.requestLanguage('semantic-tokens', {}); assert.equal(full.data.updateKind, 'full'); + assert.ok(decodeSemanticTokens(host.validateLanguageResult(full).data, sample).some(token => token.style === 'string')); + const [folds, hints, tree] = await Promise.all([host.requestLanguage('folding-ranges', {}), host.requestLanguage('inlay-hints', {range: {start: {line: 0, character: 0}, end: {line: 4, character: 0}}}), host.requestLanguage('document-symbol-tree', {})]); + assert.equal(folds.data.length, 1); assert.equal(hints.data[1].label, literalHint); assert.equal(tree.data[0].children.length, 2); + host.setDocument({...document, modelVersion: 2, text: sample.replace('12', '12345')}); + assert.throws(() => host.validateLanguageResult(full), error => error.code === 'stale_snapshot'); + const delta = await host.requestLanguage('semantic-tokens', {previousResultId: full.data.resultId}); assert.equal(delta.data.updateKind, 'delta'); + host.releaseLanguageResult(delta); + const fallback = await host.requestLanguage('semantic-tokens', {previousResultId: delta.data.resultId}); assert.equal(fallback.data.updateKind, 'fallback'); + assert.deepEqual(counters, {full: 2, delta: 1}); activation.dispose(); + assert.throws(() => host.validateLanguageResult(fallback), error => error.code === 'disposed'); + await host.activate(plugin); assert.equal((await host.requestLanguage('semantic-tokens', {previousResultId: fallback.data.resultId})).data.updateKind, 'fallback'); + console.log('Language display: full, delta, lost-base fallback, folds, literal hints, symbol tree and stale disposal verified'); +} finally {host.dispose(); host.dispose();} diff --git a/examples/language-display/serve.mjs b/examples/language-display/serve.mjs new file mode 100644 index 0000000..ea89a7b --- /dev/null +++ b/examples/language-display/serve.mjs @@ -0,0 +1,18 @@ +import {readFile, readdir} from 'node:fs/promises'; +import {createServer} from 'node:http'; +const root = new URL('./', import.meta.url), source = new URL('../../src/', import.meta.url); +const files = new Map([['/', ['browser.html', 'text/html; charset=utf-8']], ...['browser.mjs', 'plugin.mjs'].map(name => ['/' + name, [name, 'text/javascript; charset=utf-8']])]); +const modules = new Set((await readdir(source)).filter(name => /^[a-z0-9-]+\.mjs$/.test(name))); +const server = createServer(async (request, response) => { + try { + if (request.method !== 'GET') {response.writeHead(405).end(); return;} + let entry = files.get(request.url), base = root; + if (!entry && request.url.startsWith('/sdk/') && modules.has(request.url.slice(5))) {entry = [request.url.slice(5), 'text/javascript; charset=utf-8']; base = source;} + if (!entry) {response.writeHead(404).end(); return;} + const body = await readFile(new URL(entry[0], base)); + response.writeHead(200, {'content-type': entry[1], 'cache-control': 'no-store', 'x-content-type-options': 'nosniff'}).end(body); + } catch {response.writeHead(404).end();} +}); +await new Promise((resolve, reject) => {server.once('error', reject); server.listen(0, '127.0.0.1', resolve);}); +console.log('Language display example: http://127.0.0.1:' + server.address().port); +for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => {server.close(); server.closeAllConnections();}); diff --git a/package.json b/package.json index bb7d2cc..7bdd1d6 100644 --- a/package.json +++ b/package.json @@ -191,7 +191,9 @@ "example:browser-resume": "node examples/browser-resume/serve.mjs", "example:assistance": "node examples/language-assistance/run.mjs", "example:workspace-edits": "node examples/workspace-edits/run.mjs", - "example:language-editing": "node examples/language-editing/run.mjs" + "example:language-editing": "node examples/language-editing/run.mjs", + "example:language-display": "node examples/language-display/run.mjs", + "example:language-display-browser": "node examples/language-display/serve.mjs" }, "devDependencies": { "typescript": "5.9.3" diff --git a/schemas/document-symbol-tree.schema.json b/schemas/document-symbol-tree.schema.json new file mode 100644 index 0000000..42ac22e --- /dev/null +++ b/schemas/document-symbol-tree.schema.json @@ -0,0 +1,158 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/document-symbol-tree.schema.json", + "title": "document-symbol-tree", + "type": "array", + "items": { + "$ref": "#/$defs/symbol" + }, + "maxItems": 1000, + "minItems": 0, + "$defs": { + "symbol": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "kind": { + "enum": [ + "module", + "namespace", + "class", + "interface", + "function", + "method", + "variable", + "constant", + "property", + "type" + ] + }, + "range": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + }, + "end": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false + }, + "selectionRange": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + }, + "end": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false + }, + "detail": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "children": { + "type": "array", + "items": { + "$ref": "#/$defs/symbol" + }, + "maxItems": 1000, + "minItems": 0 + } + }, + "required": [ + "name", + "kind", + "range", + "selectionRange" + ], + "additionalProperties": false + } + }, + "$comment": "Runtime limits the entire tree to 1000 symbols and 16 levels, with contained selections/children and ordered nonoverlapping siblings." +} diff --git a/schemas/folding-ranges.schema.json b/schemas/folding-ranges.schema.json new file mode 100644 index 0000000..fba88c1 --- /dev/null +++ b/schemas/folding-ranges.schema.json @@ -0,0 +1,80 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/folding-ranges.schema.json", + "title": "folding-ranges", + "type": "array", + "items": { + "type": "object", + "properties": { + "range": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + }, + "end": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false + }, + "kind": { + "enum": [ + "region", + "comment", + "imports" + ] + }, + "collapsedText": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "range" + ], + "additionalProperties": false + }, + "maxItems": 1000, + "minItems": 0, + "$comment": "Runtime rejects empty, duplicate, crossing or unordered ranges and nesting deeper than 32." +} diff --git a/schemas/inlay-hints.schema.json b/schemas/inlay-hints.schema.json new file mode 100644 index 0000000..7afd9f7 --- /dev/null +++ b/schemas/inlay-hints.schema.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/inlay-hints.schema.json", + "title": "inlay-hints", + "type": "array", + "items": { + "type": "object", + "properties": { + "position": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^[^\\r\\n\\u0000]+$" + }, + "kind": { + "enum": [ + "type", + "parameter" + ] + }, + "paddingLeft": { + "type": "boolean" + }, + "paddingRight": { + "type": "boolean" + }, + "tooltip": { + "type": "string", + "maxLength": 4096 + } + }, + "required": [ + "position", + "label" + ], + "additionalProperties": false + }, + "maxItems": 1000, + "minItems": 0, + "$comment": "Plain text only. Runtime checks sorted positions within the requested range and document." +} diff --git a/schemas/language-capabilities.schema.json b/schemas/language-capabilities.schema.json index dd0c041..c2c92d2 100644 --- a/schemas/language-capabilities.schema.json +++ b/schemas/language-capabilities.schema.json @@ -19,7 +19,11 @@ "rename", "format-document", "format-range", - "code-actions" + "code-actions", + "semantic-tokens", + "folding-ranges", + "inlay-hints", + "document-symbol-tree" ] }, "completionResolve": { @@ -28,6 +32,9 @@ "codeActionResolve": { "const": true }, + "semanticTokensDelta": { + "const": true + }, "snippets": { "const": true }, @@ -89,6 +96,62 @@ "resolvePending" ], "additionalProperties": false + }, + "displayLimits": { + "type": "object", + "properties": { + "semanticTokens": { + "const": 20000 + }, + "tokenTypes": { + "const": 64 + }, + "tokenModifiers": { + "const": 16 + }, + "deltaEdits": { + "const": 64 + }, + "semanticCacheEntries": { + "const": 8 + }, + "semanticCacheBytes": { + "const": 4194304 + }, + "semanticCacheTtlMs": { + "const": 60000 + }, + "foldingRanges": { + "const": 1000 + }, + "foldingDepth": { + "const": 32 + }, + "inlayHints": { + "const": 1000 + }, + "symbols": { + "const": 1000 + }, + "symbolDepth": { + "const": 16 + } + }, + "required": [ + "semanticTokens", + "tokenTypes", + "tokenModifiers", + "deltaEdits", + "semanticCacheEntries", + "semanticCacheBytes", + "semanticCacheTtlMs", + "foldingRanges", + "foldingDepth", + "inlayHints", + "symbols", + "symbolDepth" + ], + "additionalProperties": false } }, "required": [ @@ -96,9 +159,11 @@ "features", "completionResolve", "codeActionResolve", + "semanticTokensDelta", "snippets", "positions", - "limits" + "limits", + "displayLimits" ], "additionalProperties": false } diff --git a/schemas/language-request.schema.json b/schemas/language-request.schema.json index b53624c..1768e21 100644 --- a/schemas/language-request.schema.json +++ b/schemas/language-request.schema.json @@ -1543,6 +1543,399 @@ "diagnosticContext" ], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "protocolVersion": { + "const": 1 + }, + "requestId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "scope": { + "type": "object", + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "sessionId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "projectId", + "sessionId" + ], + "additionalProperties": false + }, + "snapshot": { + "type": "object", + "properties": { + "uri": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "pattern": "^[A-Za-z][A-Za-z0-9+.-]*:[^\\s\\u0000-\\u001f\\u007f]+$" + }, + "languageId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$" + }, + "modelVersion": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "workspaceRevision": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "text": { + "type": "string", + "maxLength": 262144, + "x-maxUtf8Bytes": 262144 + } + }, + "required": [ + "uri", + "languageId", + "modelVersion", + "workspaceRevision", + "text" + ], + "additionalProperties": false + }, + "kind": { + "const": "semantic-tokens" + }, + "previousResultId": { + "type": "string", + "minLength": 1, + "maxLength": 36, + "pattern": "^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$" + } + }, + "required": [ + "protocolVersion", + "requestId", + "scope", + "snapshot", + "kind" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "protocolVersion": { + "const": 1 + }, + "requestId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "scope": { + "type": "object", + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "sessionId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "projectId", + "sessionId" + ], + "additionalProperties": false + }, + "snapshot": { + "type": "object", + "properties": { + "uri": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "pattern": "^[A-Za-z][A-Za-z0-9+.-]*:[^\\s\\u0000-\\u001f\\u007f]+$" + }, + "languageId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$" + }, + "modelVersion": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "workspaceRevision": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "text": { + "type": "string", + "maxLength": 262144, + "x-maxUtf8Bytes": 262144 + } + }, + "required": [ + "uri", + "languageId", + "modelVersion", + "workspaceRevision", + "text" + ], + "additionalProperties": false + }, + "kind": { + "const": "folding-ranges" + } + }, + "required": [ + "protocolVersion", + "requestId", + "scope", + "snapshot", + "kind" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "protocolVersion": { + "const": 1 + }, + "requestId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "scope": { + "type": "object", + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "sessionId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "projectId", + "sessionId" + ], + "additionalProperties": false + }, + "snapshot": { + "type": "object", + "properties": { + "uri": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "pattern": "^[A-Za-z][A-Za-z0-9+.-]*:[^\\s\\u0000-\\u001f\\u007f]+$" + }, + "languageId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$" + }, + "modelVersion": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "workspaceRevision": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "text": { + "type": "string", + "maxLength": 262144, + "x-maxUtf8Bytes": 262144 + } + }, + "required": [ + "uri", + "languageId", + "modelVersion", + "workspaceRevision", + "text" + ], + "additionalProperties": false + }, + "kind": { + "const": "inlay-hints" + }, + "range": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + }, + "end": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false + } + }, + "required": [ + "protocolVersion", + "requestId", + "scope", + "snapshot", + "kind", + "range" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "protocolVersion": { + "const": 1 + }, + "requestId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "scope": { + "type": "object", + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "sessionId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "projectId", + "sessionId" + ], + "additionalProperties": false + }, + "snapshot": { + "type": "object", + "properties": { + "uri": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "pattern": "^[A-Za-z][A-Za-z0-9+.-]*:[^\\s\\u0000-\\u001f\\u007f]+$" + }, + "languageId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$" + }, + "modelVersion": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "workspaceRevision": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "text": { + "type": "string", + "maxLength": 262144, + "x-maxUtf8Bytes": 262144 + } + }, + "required": [ + "uri", + "languageId", + "modelVersion", + "workspaceRevision", + "text" + ], + "additionalProperties": false + }, + "kind": { + "const": "document-symbol-tree" + } + }, + "required": [ + "protocolVersion", + "requestId", + "scope", + "snapshot", + "kind" + ], + "additionalProperties": false } ] } diff --git a/schemas/language-result.schema.json b/schemas/language-result.schema.json index 38e3e99..bb786dc 100644 --- a/schemas/language-result.schema.json +++ b/schemas/language-result.schema.json @@ -2644,6 +2644,552 @@ "data" ], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "protocolVersion": { + "const": 1 + }, + "requestId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "scope": { + "type": "object", + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "sessionId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "projectId", + "sessionId" + ], + "additionalProperties": false + }, + "snapshot": { + "type": "object", + "properties": { + "uri": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "pattern": "^[A-Za-z][A-Za-z0-9+.-]*:[^\\s\\u0000-\\u001f\\u007f]+$" + }, + "languageId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$" + }, + "modelVersion": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "workspaceRevision": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "uri", + "languageId", + "modelVersion", + "workspaceRevision" + ], + "additionalProperties": false + }, + "kind": { + "const": "semantic-tokens" + }, + "data": { + "type": "object", + "properties": { + "resultId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[^\\u0000-\\u001f\\u007f]+$" + }, + "legend": { + "type": "object", + "properties": { + "tokenTypes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[A-Za-z][A-Za-z0-9_.-]*$" + }, + "style": { + "enum": [ + "plain", + "keyword", + "type", + "function", + "variable", + "number", + "string", + "comment", + "operator" + ] + } + }, + "required": [ + "name", + "style" + ], + "additionalProperties": false + }, + "maxItems": 64, + "minItems": 1 + }, + "tokenModifiers": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[A-Za-z][A-Za-z0-9_.-]*$" + }, + "maxItems": 16, + "minItems": 0, + "uniqueItems": true + } + }, + "required": [ + "tokenTypes", + "tokenModifiers" + ], + "additionalProperties": false, + "$comment": "Runtime enforces unique type names." + }, + "data": { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 2147483647 + }, + "maxItems": 100000, + "minItems": 0 + }, + "updateKind": { + "enum": [ + "full", + "delta", + "fallback" + ] + } + }, + "required": [ + "resultId", + "legend", + "data" + ], + "additionalProperties": false, + "$comment": "Five integers per token. Runtime validates legend references, sorted nonoverlapping UTF-16 ranges and the current document. Host overwrites resultId and updateKind." + } + }, + "required": [ + "protocolVersion", + "requestId", + "scope", + "snapshot", + "kind", + "data" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "protocolVersion": { + "const": 1 + }, + "requestId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "scope": { + "type": "object", + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "sessionId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "projectId", + "sessionId" + ], + "additionalProperties": false + }, + "snapshot": { + "type": "object", + "properties": { + "uri": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "pattern": "^[A-Za-z][A-Za-z0-9+.-]*:[^\\s\\u0000-\\u001f\\u007f]+$" + }, + "languageId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$" + }, + "modelVersion": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "workspaceRevision": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "uri", + "languageId", + "modelVersion", + "workspaceRevision" + ], + "additionalProperties": false + }, + "kind": { + "const": "folding-ranges" + }, + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "range": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + }, + "end": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": false + }, + "kind": { + "enum": [ + "region", + "comment", + "imports" + ] + }, + "collapsedText": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "range" + ], + "additionalProperties": false + }, + "maxItems": 1000, + "minItems": 0, + "$comment": "Runtime rejects empty, duplicate, crossing or unordered ranges and nesting deeper than 32." + } + }, + "required": [ + "protocolVersion", + "requestId", + "scope", + "snapshot", + "kind", + "data" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "protocolVersion": { + "const": 1 + }, + "requestId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "scope": { + "type": "object", + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "sessionId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "projectId", + "sessionId" + ], + "additionalProperties": false + }, + "snapshot": { + "type": "object", + "properties": { + "uri": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "pattern": "^[A-Za-z][A-Za-z0-9+.-]*:[^\\s\\u0000-\\u001f\\u007f]+$" + }, + "languageId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$" + }, + "modelVersion": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "workspaceRevision": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "uri", + "languageId", + "modelVersion", + "workspaceRevision" + ], + "additionalProperties": false + }, + "kind": { + "const": "inlay-hints" + }, + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "position": { + "type": "object", + "properties": { + "line": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + }, + "character": { + "type": "integer", + "minimum": 0, + "maximum": 262144 + } + }, + "required": [ + "line", + "character" + ], + "additionalProperties": false + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^[^\\r\\n\\u0000]+$" + }, + "kind": { + "enum": [ + "type", + "parameter" + ] + }, + "paddingLeft": { + "type": "boolean" + }, + "paddingRight": { + "type": "boolean" + }, + "tooltip": { + "type": "string", + "maxLength": 4096 + } + }, + "required": [ + "position", + "label" + ], + "additionalProperties": false + }, + "maxItems": 1000, + "minItems": 0, + "$comment": "Plain text only. Runtime checks sorted positions within the requested range and document." + } + }, + "required": [ + "protocolVersion", + "requestId", + "scope", + "snapshot", + "kind", + "data" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "protocolVersion": { + "const": 1 + }, + "requestId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "scope": { + "type": "object", + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "sessionId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "projectId", + "sessionId" + ], + "additionalProperties": false + }, + "snapshot": { + "type": "object", + "properties": { + "uri": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "pattern": "^[A-Za-z][A-Za-z0-9+.-]*:[^\\s\\u0000-\\u001f\\u007f]+$" + }, + "languageId": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$" + }, + "modelVersion": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "workspaceRevision": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "required": [ + "uri", + "languageId", + "modelVersion", + "workspaceRevision" + ], + "additionalProperties": false + }, + "kind": { + "const": "document-symbol-tree" + }, + "data": { + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/document-symbol-tree.schema.json" + } + }, + "required": [ + "protocolVersion", + "requestId", + "scope", + "snapshot", + "kind", + "data" + ], + "additionalProperties": false } ] } diff --git a/schemas/manifest.schema.json b/schemas/manifest.schema.json index 6518c80..574c966 100644 --- a/schemas/manifest.schema.json +++ b/schemas/manifest.schema.json @@ -192,13 +192,18 @@ "format-document", "format-range", "code-actions", + "semantic-tokens", + "folding-ranges", + "inlay-hints", + "document-symbol-tree", "completion-resolve", "completion-snippets", - "code-action-resolve" + "code-action-resolve", + "semantic-tokens-delta" ] }, "minItems": 1, - "maxItems": 16, + "maxItems": 21, "uniqueItems": true }, "permissions": { @@ -349,6 +354,26 @@ } } } + }, + { + "if": { + "properties": { + "capabilities": { + "contains": { + "const": "semantic-tokens-delta" + } + } + } + }, + "then": { + "properties": { + "capabilities": { + "contains": { + "const": "semantic-tokens" + } + } + } + } } ] } diff --git a/schemas/semantic-legend.schema.json b/schemas/semantic-legend.schema.json new file mode 100644 index 0000000..9606e93 --- /dev/null +++ b/schemas/semantic-legend.schema.json @@ -0,0 +1,60 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/semantic-legend.schema.json", + "title": "semantic-legend", + "type": "object", + "properties": { + "tokenTypes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[A-Za-z][A-Za-z0-9_.-]*$" + }, + "style": { + "enum": [ + "plain", + "keyword", + "type", + "function", + "variable", + "number", + "string", + "comment", + "operator" + ] + } + }, + "required": [ + "name", + "style" + ], + "additionalProperties": false + }, + "maxItems": 64, + "minItems": 1 + }, + "tokenModifiers": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[A-Za-z][A-Za-z0-9_.-]*$" + }, + "maxItems": 16, + "minItems": 0, + "uniqueItems": true + } + }, + "required": [ + "tokenTypes", + "tokenModifiers" + ], + "additionalProperties": false, + "$comment": "Runtime enforces unique type names." +} diff --git a/schemas/semantic-tokens-delta.schema.json b/schemas/semantic-tokens-delta.schema.json new file mode 100644 index 0000000..072a3ee --- /dev/null +++ b/schemas/semantic-tokens-delta.schema.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/semantic-tokens-delta.schema.json", + "title": "semantic-tokens-delta", + "type": "object", + "properties": { + "baseResultId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[^\\u0000-\\u001f\\u007f]+$" + }, + "resultId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[^\\u0000-\\u001f\\u007f]+$" + }, + "edits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "minimum": 0, + "maximum": 100000 + }, + "deleteCount": { + "type": "integer", + "minimum": 0, + "maximum": 100000 + }, + "data": { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 2147483647 + }, + "maxItems": 100000, + "minItems": 0 + } + }, + "required": [ + "start", + "deleteCount" + ], + "additionalProperties": false + }, + "maxItems": 64, + "minItems": 0 + } + }, + "required": [ + "baseResultId", + "resultId", + "edits" + ], + "additionalProperties": false, + "$comment": "Edits address the original integer array, are ordered and disjoint, and share the total insertion budget. The complete reconstructed result is validated against the new document." +} diff --git a/schemas/semantic-tokens.schema.json b/schemas/semantic-tokens.schema.json new file mode 100644 index 0000000..ba16907 --- /dev/null +++ b/schemas/semantic-tokens.schema.json @@ -0,0 +1,95 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.11.0/schemas/semantic-tokens.schema.json", + "title": "semantic-tokens", + "type": "object", + "properties": { + "resultId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[^\\u0000-\\u001f\\u007f]+$" + }, + "legend": { + "type": "object", + "properties": { + "tokenTypes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[A-Za-z][A-Za-z0-9_.-]*$" + }, + "style": { + "enum": [ + "plain", + "keyword", + "type", + "function", + "variable", + "number", + "string", + "comment", + "operator" + ] + } + }, + "required": [ + "name", + "style" + ], + "additionalProperties": false + }, + "maxItems": 64, + "minItems": 1 + }, + "tokenModifiers": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[A-Za-z][A-Za-z0-9_.-]*$" + }, + "maxItems": 16, + "minItems": 0, + "uniqueItems": true + } + }, + "required": [ + "tokenTypes", + "tokenModifiers" + ], + "additionalProperties": false, + "$comment": "Runtime enforces unique type names." + }, + "data": { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 2147483647 + }, + "maxItems": 100000, + "minItems": 0 + }, + "updateKind": { + "enum": [ + "full", + "delta", + "fallback" + ] + } + }, + "required": [ + "resultId", + "legend", + "data" + ], + "additionalProperties": false, + "$comment": "Five integers per token. Runtime validates legend references, sorted nonoverlapping UTF-16 ranges and the current document. Host overwrites resultId and updateKind." +} diff --git a/scripts/pack-check.mjs b/scripts/pack-check.mjs index c8237bb..471bf1d 100644 --- a/scripts/pack-check.mjs +++ b/scripts/pack-check.mjs @@ -104,6 +104,8 @@ assert.equal(typeof createDiagnosticsResult, 'function'); assert.match(run(process.execPath, ['edits-example/run.mjs'], consumer), /rename preview, external-edit conflict, reviewed CAS and killed-process recovery verified/); await cp(join(installed, 'examples', 'language-editing'), join(consumer, 'format-example'), {recursive: true}); assert.match(run(process.execPath, ['format-example/run.mjs'], consumer), /range formatting, CRLF preservation, diagnostic quick-fix, conflict and selected approval verified/); + await cp(join(installed, 'examples', 'language-display'), join(consumer, 'display-example'), {recursive: true}); + assert.match(run(process.execPath, ['display-example/run.mjs'], consumer), /full, delta, lost-base fallback, folds, literal hints, symbol tree and stale disposal verified/); await cp(join(installed, 'examples', 'project-session'), join(consumer, 'project-example'), {recursive: true}); run(process.execPath, ['project-example/run.mjs'], consumer); // Package and install a real independent plugin. npm sees generated metadata; @@ -201,6 +203,7 @@ try { await cp(join(root, 'tests', 'types', 'language-assistance.mts'), join(consumer, 'language-assistance.mts')); await cp(join(root, 'tests', 'types', 'workspace-edits.mts'), join(consumer, 'workspace-edits.mts')); await cp(join(root, 'tests', 'types', 'language-editing.mts'), join(consumer, 'language-editing.mts')); + await cp(join(root, 'tests', 'types', 'language-display.mts'), join(consumer, 'language-display.mts')); await cp(join(root, 'tests', 'types', 'workspace-project.mts'), join(consumer, 'workspace-project.mts')); await cp(join(root, 'tests', 'types', 'jobs-devtools.mts'), join(consumer, 'jobs-devtools.mts')); await cp(join(root, 'tests', 'types', 'workspace-observation.mts'), join(consumer, 'workspace-observation.mts')); diff --git a/src/contracts.mjs b/src/contracts.mjs index c291a0c..c4be0f4 100644 --- a/src/contracts.mjs +++ b/src/contracts.mjs @@ -4,6 +4,7 @@ import {parseCompletionItem, parseLanguageContext, parseSignatureHelp, completio import {textIndex, languageText, languageCall} from './language-values.mjs'; import {parseWorkspaceEdit} from './workspace-edit-contracts.mjs'; import {parseFormattingOptions, parseCodeActionContext, parseCodeActions, validateFormattingEdit, validateCodeActions} from './language-editing.mjs'; +import {parseSemanticTokens, parseFoldingRanges, parseInlayHints, parseDocumentSymbolTree, validateLanguageDisplay} from './language-display.mjs'; const encoder = new TextEncoder(); const identifierPattern = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/; @@ -12,8 +13,8 @@ const uriPattern = /^[A-Za-z][A-Za-z0-9+.-]*:[^\s\u0000-\u001f\u007f]+$/; const permissions = ['document.read', 'diagnostics.publish']; const workspacePermissions = ['workspace.read', 'workspace.write', 'backend.invoke']; const allPermissions = [...permissions, ...workspacePermissions, 'language.provide']; -export const LANGUAGE_FEATURES = Object.freeze(['completion', 'hover', 'definition', 'references', 'document-symbols', 'signature-help', 'prepare-rename', 'rename', 'format-document', 'format-range', 'code-actions']); -export const LANGUAGE_CAPABILITIES = Object.freeze([...LANGUAGE_FEATURES, 'completion-resolve', 'completion-snippets', 'code-action-resolve']); +export const LANGUAGE_FEATURES = Object.freeze(['completion', 'hover', 'definition', 'references', 'document-symbols', 'signature-help', 'prepare-rename', 'rename', 'format-document', 'format-range', 'code-actions', 'semantic-tokens', 'folding-ranges', 'inlay-hints', 'document-symbol-tree']); +export const LANGUAGE_CAPABILITIES = Object.freeze([...LANGUAGE_FEATURES, 'completion-resolve', 'completion-snippets', 'code-action-resolve', 'semantic-tokens-delta']); function fail(path, message) { throw new PluginSdkError(ErrorCode.INVALID_CONTRACT, `${path}: ${message}`); @@ -175,6 +176,7 @@ export function parseManifest(value) { if (manifestVersion === 2) { if (output.capabilities.some(capability => ['completion-resolve', 'completion-snippets'].includes(capability)) && !output.capabilities.includes('completion')) fail('manifest.capabilities', 'completion extension requires completion'); if (output.capabilities.includes('code-action-resolve') && !output.capabilities.includes('code-actions')) fail('manifest.capabilities', 'code action resolve requires code-actions'); + if (output.capabilities.includes('semantic-tokens-delta') && !output.capabilities.includes('semantic-tokens')) fail('manifest.capabilities', 'semantic delta requires semantic-tokens'); output.runtime = enumeration(value.runtime, ['ui', 'workspace'], 'manifest.runtime'); if (output.runtime === 'ui' && output.permissions.some(permission => workspacePermissions.includes(permission))) fail('manifest.permissions', 'UI plugins cannot request workspace or backend permissions'); record(value.source, ['visibility', 'licenseFile'], ['repository'], 'manifest.source'); @@ -433,7 +435,7 @@ function inDocumentRange(value, text) { /** Language API v1: returned text is never HTML or a command. Snippets are explicit data. */ export function parseLanguageRequest(value) { value = input(value, LIMITS.requestBytes, 'request'); - record(value, ['protocolVersion', 'requestId', 'scope', 'snapshot', 'kind'], ['position', 'includeDeclaration', 'context', 'newName', 'range', 'path', 'formatOptions', 'diagnosticContext'], 'request'); + record(value, ['protocolVersion', 'requestId', 'scope', 'snapshot', 'kind'], ['position', 'includeDeclaration', 'context', 'newName', 'range', 'path', 'formatOptions', 'diagnosticContext', 'previousResultId'], 'request'); const kind = enumeration(value.kind, LANGUAGE_FEATURES, 'request.kind'); const output = { protocolVersion: version(value.protocolVersion, 'request.protocolVersion'), @@ -441,7 +443,7 @@ export function parseLanguageRequest(value) { scope: scope(value.scope, 'request.scope'), snapshot: snapshot(value.snapshot, 'request.snapshot', true), kind, }; const formatting = ['format-document', 'format-range'].includes(kind); - if (kind === 'document-symbols' || formatting || kind === 'code-actions') { + if (['document-symbols', 'code-actions', 'semantic-tokens', 'folding-ranges', 'inlay-hints', 'document-symbol-tree'].includes(kind) || formatting) { if (Object.hasOwn(value, 'position')) fail('request.position', 'this feature does not take a position'); } else { output.position = position(value.position, 'request.position'); @@ -456,7 +458,7 @@ export function parseLanguageRequest(value) { else if (Object.hasOwn(value, 'context')) output.context = parseLanguageContext(kind, value.context); if (formatting || kind === 'code-actions') output.path = parseWorkspacePath(value.path); else if (Object.hasOwn(value, 'path')) fail('request.path', 'path is unsupported for this feature'); - if (kind === 'format-range' || kind === 'code-actions') { + if (['format-range', 'code-actions', 'inlay-hints'].includes(kind)) { output.range = range(value.range, 'request.range'); textIndex(output.snapshot.text).range(output.range); } else if (Object.hasOwn(value, 'range')) fail('request.range', 'range is unsupported for this feature'); if (formatting) output.formatOptions = parseFormattingOptions(value.formatOptions); @@ -467,6 +469,10 @@ export function parseLanguageRequest(value) { const index = textIndex(output.snapshot.text); for (const diagnostic of diagnostics) index.range(diagnostic.range); output.diagnosticContext = Object.freeze({revision: integer(value.diagnosticContext.revision, Number.MAX_SAFE_INTEGER, 'request.diagnosticContext.revision'), diagnostics}); } else if (Object.hasOwn(value, 'diagnosticContext')) fail('request.diagnosticContext', 'diagnostics are unsupported for this feature'); + if (Object.hasOwn(value, 'previousResultId')) { + if (kind !== 'semantic-tokens') fail('request.previousResultId', 'only semantic tokens accept a previous result'); + output.previousResultId = string(value.previousResultId, 36, 'request.previousResultId', /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/); + } if (kind === 'rename') { output.newName = languageText(value.newName, 256); if (/[\u0000-\u001f\u007f]/.test(output.newName)) fail('request.newName', 'control characters are unsupported'); @@ -522,6 +528,10 @@ export function parseLanguageResult(value) { : kind === 'rename' ? value.data === null ? null : languageCall(() => parseWorkspaceEdit(value.data)) : ['format-document', 'format-range'].includes(kind) ? value.data === null ? null : languageCall(() => parseWorkspaceEdit(value.data)) : kind === 'code-actions' ? parseCodeActions(value.data) + : kind === 'semantic-tokens' ? parseSemanticTokens(value.data) + : kind === 'folding-ranges' ? parseFoldingRanges(value.data) + : kind === 'inlay-hints' ? parseInlayHints(value.data) + : kind === 'document-symbol-tree' ? parseDocumentSymbolTree(value.data) : kind === 'hover' ? hover(value.data, 'result.data') : Object.freeze(array(value.data, LIMITS.maxLanguageItems, 'result.data', readers[kind])), }), LIMITS.resultBytes, 'result'); } @@ -529,6 +539,7 @@ export function parseLanguageResult(value) { export function assertLanguageResultMatchesRequest(result, request) { assertIdentityMatches(result, request); if (result.kind !== request.kind) fail('result.kind', 'does not match request'); + if (['semantic-tokens', 'folding-ranges', 'inlay-hints', 'document-symbol-tree'].includes(result.kind)) {validateLanguageDisplay(request, result.data); return;} if (['signature-help', 'rename'].includes(result.kind)) return; if (['format-document', 'format-range'].includes(result.kind)) {validateFormattingEdit(request, result.data); return;} if (result.kind === 'code-actions') {validateCodeActions(request, result.data); return;} diff --git a/src/host.mjs b/src/host.mjs index 442f978..bff8b1a 100644 --- a/src/host.mjs +++ b/src/host.mjs @@ -4,6 +4,7 @@ import { createDiagnosticsRegistry } from './lifecycle.mjs'; import { createLanguageRegistry } from './lifecycle.mjs'; import { LANGUAGE_FEATURES, parseLanguageRequest } from './contracts.mjs'; import {LANGUAGE_LIMITS} from './language-assistance.mjs'; +import {LANGUAGE_DISPLAY_LIMITS} from './language-display.mjs'; import {SDK_VERSION} from './version.mjs'; import { ErrorCode, LIMITS, PluginSdkError } from './limits.mjs'; import {createJobRegistry} from './job-registry.mjs'; @@ -178,6 +179,7 @@ export function createPluginHost({hostId = 'test-host', scope = {projectId: 'exa try { registration = languages.get(kind).register(manifest.id, selector, provider, { resolve: kind === 'completion' && manifest.capabilities.includes('completion-resolve') || kind === 'code-actions' && manifest.capabilities.includes('code-action-resolve'), snippets: kind === 'completion' && manifest.capabilities.includes('completion-snippets'), + semanticDelta: kind === 'semantic-tokens' && manifest.capabilities.includes('semantic-tokens-delta'), }); } catch (failure) { throw error(failure instanceof PluginSdkError ? failure.code : ErrorCode.PROVIDER_FAILED, 'Language provider registration failed'); } state.registrations.add(registration); @@ -340,7 +342,8 @@ export function createPluginHost({hostId = 'test-host', scope = {projectId: 'exa throw error(ErrorCode.INVALID_CONTRACT, 'Changed document requires a newer modelVersion or workspaceRevision'); } registry.invalidate(); - for (const language of languages.values()) language.invalidate(); + const retainSemantic = !!document && document.uri === updated.uri && document.languageId === updated.languageId && updated.modelVersion > document.modelVersion; + for (const language of languages.values()) language.invalidate({retainSemantic}); diagnosticItems = Object.freeze([]); diagnosticRevision++; acceptedDiagnosticSequence = ++diagnosticSequence; document = updated; revision++; @@ -361,7 +364,7 @@ export function createPluginHost({hostId = 'test-host', scope = {projectId: 'exa if (!document) throw error(ErrorCode.INVALID_CONTRACT, 'Set a document before requesting language features'); if (!languages.has(kind)) throw error(ErrorCode.INVALID_CONTRACT, 'Unknown language feature'); const value = parseJsonValue(input); - if (!value || Array.isArray(value) || typeof value !== 'object' || Object.keys(value).some(key => !['position', 'includeDeclaration', 'context', 'newName', 'range', 'path', 'formatOptions'].includes(key))) throw error(ErrorCode.INVALID_CONTRACT, 'Expected language request input'); + if (!value || Array.isArray(value) || typeof value !== 'object' || Object.keys(value).some(key => !['position', 'includeDeclaration', 'context', 'newName', 'range', 'path', 'formatOptions', 'previousResultId'].includes(key))) throw error(ErrorCode.INVALID_CONTRACT, 'Expected language request input'); const diagnostics = kind === 'code-actions' ? {diagnosticContext: {revision: diagnosticRevision, diagnostics: diagnosticItems}} : {}; const request = parseLanguageRequest({protocolVersion: 1, requestId: `request-${++nextRequest}`, scope, snapshot: document, kind, ...value, ...diagnostics}); const currentRevision = revision; @@ -370,7 +373,17 @@ export function createPluginHost({hostId = 'test-host', scope = {projectId: 'exa if (revision !== currentRevision || kind === 'code-actions' && diagnosticRevision !== currentDiagnostics) throw error(ErrorCode.STALE_SNAPSHOT, 'Document or diagnostics changed while language features were pending'); return result; }, - languageCapabilities() {assertOpen(); return Object.freeze({protocolVersion: 1, features: LANGUAGE_FEATURES, completionResolve: true, codeActionResolve: true, snippets: true, positions: 'utf16-zero-based', limits: LANGUAGE_LIMITS});}, + languageCapabilities() {assertOpen(); return Object.freeze({protocolVersion: 1, features: LANGUAGE_FEATURES, completionResolve: true, codeActionResolve: true, semanticTokensDelta: true, snippets: true, positions: 'utf16-zero-based', limits: LANGUAGE_LIMITS, displayLimits: LANGUAGE_DISPLAY_LIMITS});}, + validateLanguageResult(result) { + assertOpen(); const kind = Object.getOwnPropertyDescriptor(result ?? {}, 'kind')?.value; + if (!languages.has(kind)) throw error(ErrorCode.INVALID_CONTRACT, 'Expected a language result'); + return languages.get(kind).validateResult(result); + }, + releaseLanguageResult(result) { + assertOpen(); const kind = Object.getOwnPropertyDescriptor(result ?? {}, 'kind')?.value; + if (!languages.has(kind)) throw error(ErrorCode.INVALID_CONTRACT, 'Expected a language result'); + languages.get(kind).release(result); + }, async resolveCompletion(token, options = {}) { assertOpen(); const currentRevision = revision; const result = await languages.get('completion').resolve(token, options); diff --git a/src/index.d.mts b/src/index.d.mts index 91b001f..57dc060 100644 --- a/src/index.d.mts +++ b/src/index.d.mts @@ -162,6 +162,8 @@ export interface PluginHost extends Disposable { requestDiagnostics(options?: RequestOptions): Promise; requestLanguage(kind: K, input: LanguageInput, options?: RequestOptions): Promise>; languageCapabilities(): LanguageCapabilities; + validateLanguageResult(result: LanguageResult): LanguageResult; + releaseLanguageResult(result: LanguageResult): void; resolveCompletion(token: string, options?: RequestOptions): Promise>; prepareCompletion(result: LanguageResult<'completion'>, itemIndex?: number): CompletionInsertion; releaseCompletion(result: LanguageResult<'completion'>): void; @@ -197,14 +199,17 @@ export interface PluginHost extends Disposable { export function createPluginHost(options?: PluginHostOptions): PluginHost; export type FormattingFeature = 'format-document' | 'format-range'; -export type LanguageFeature = 'completion' | 'hover' | 'definition' | 'references' | 'document-symbols' | 'signature-help' | 'prepare-rename' | 'rename' | FormattingFeature | 'code-actions'; -export type LanguageCapability = LanguageFeature | 'completion-resolve' | 'completion-snippets' | 'code-action-resolve'; +export type LanguageDisplayFeature = 'semantic-tokens' | 'folding-ranges' | 'inlay-hints' | 'document-symbol-tree'; +export type LanguageFeature = 'completion' | 'hover' | 'definition' | 'references' | 'document-symbols' | 'signature-help' | 'prepare-rename' | 'rename' | FormattingFeature | 'code-actions' | LanguageDisplayFeature; +export type LanguageCapability = LanguageFeature | 'completion-resolve' | 'completion-snippets' | 'code-action-resolve' | 'semantic-tokens-delta'; export const LANGUAGE_FEATURES: readonly LanguageFeature[]; export const LANGUAGE_CAPABILITIES: readonly LanguageCapability[]; export type LanguageInput = K extends FormattingFeature ? {readonly path: string; readonly formatOptions: FormattingOptions; readonly position?: never} & (K extends 'format-range' ? {readonly range: Range} : {readonly range?: never}) : K extends 'code-actions' ? {readonly path: string; readonly range: Range; readonly context?: CodeActionContext; readonly position?: never} - : K extends 'document-symbols' + : K extends 'semantic-tokens' ? {readonly previousResultId?: string; readonly position?: never; readonly range?: never} + : K extends 'inlay-hints' ? {readonly range: Range; readonly position?: never} + : K extends 'document-symbols' | 'folding-ranges' | 'document-symbol-tree' ? {readonly position?: never; readonly includeDeclaration?: never; readonly context?: never; readonly newName?: never} : {readonly position: Position} & (K extends 'references' ? {readonly includeDeclaration?: boolean; readonly context?: never} : K extends 'completion' ? {readonly includeDeclaration?: never; readonly context?: CompletionContext} @@ -242,7 +247,7 @@ export const LANGUAGE_LIMITS: Readonly<{ }>; export interface LanguageCapabilities { readonly protocolVersion: 1; readonly features: readonly LanguageFeature[]; - readonly completionResolve: true; readonly codeActionResolve: true; readonly snippets: true; readonly positions: 'utf16-zero-based'; readonly limits: typeof LANGUAGE_LIMITS; + readonly completionResolve: true; readonly codeActionResolve: true; readonly semanticTokensDelta: true; readonly snippets: true; readonly positions: 'utf16-zero-based'; readonly limits: typeof LANGUAGE_LIMITS; readonly displayLimits: typeof LANGUAGE_DISPLAY_LIMITS; } export function parseSnippet(value: unknown): ParsedSnippet; export function parseCompletionItem(value: unknown): CompletionItem; @@ -271,6 +276,32 @@ export interface DocumentSymbol { readonly name: string; readonly kind: 'module' | 'namespace' | 'class' | 'interface' | 'function' | 'method' | 'variable' | 'constant' | 'property' | 'type'; readonly range: Range; readonly selectionRange: Range; readonly detail?: string; } +export type SemanticStyle = 'plain' | 'keyword' | 'type' | 'function' | 'variable' | 'number' | 'string' | 'comment' | 'operator'; +export const SEMANTIC_STYLE_KEYS: readonly SemanticStyle[]; +export const LANGUAGE_DISPLAY_LIMITS: Readonly<{ + semanticTokens: 20000; tokenTypes: 64; tokenModifiers: 16; deltaEdits: 64; + semanticCacheEntries: 8; semanticCacheBytes: 4194304; semanticCacheTtlMs: 60000; + foldingRanges: 1000; foldingDepth: 32; inlayHints: 1000; symbols: 1000; symbolDepth: 16; +}>; +export interface SemanticLegend {readonly tokenTypes: readonly {readonly name: string; readonly style: SemanticStyle}[]; readonly tokenModifiers: readonly string[];} +/** Five integers per token: delta line, delta start, UTF-16 length, type index, modifier bits. */ +export interface SemanticTokens {readonly resultId: string; readonly legend: SemanticLegend; readonly data: readonly number[]; readonly updateKind?: 'full' | 'delta' | 'fallback';} +export interface SemanticTokensDelta {readonly baseResultId: string; readonly resultId: string; readonly edits: readonly {readonly start: number; readonly deleteCount: number; readonly data?: readonly number[]}[];} +/** Provider-only previous data. Document text is not retained in the semantic cache. */ +export interface PreviousSemanticTokens {readonly resultId: string; readonly snapshot: SnapshotIdentity; readonly legend: SemanticLegend; readonly data: readonly number[];} +export interface DecodedSemanticToken {readonly range: Range; readonly type: string; readonly style: SemanticStyle; readonly modifiers: readonly string[];} +export interface FoldingRange {readonly range: Range; readonly kind?: 'region' | 'comment' | 'imports'; readonly collapsedText?: string;} +export interface InlayHint {readonly position: Position; readonly label: string; readonly kind?: 'type' | 'parameter'; readonly paddingLeft?: boolean; readonly paddingRight?: boolean; readonly tooltip?: string;} +export interface DocumentSymbolTree extends DocumentSymbol {readonly children?: readonly DocumentSymbolTree[];} +export function parseSemanticLegend(value: unknown): SemanticLegend; +export function parseSemanticTokens(value: unknown): SemanticTokens; +export function parseSemanticTokensDelta(value: unknown): SemanticTokensDelta; +export function validateSemanticTokens(value: unknown, content: string): SemanticTokens; +export function decodeSemanticTokens(value: unknown, content: string): readonly DecodedSemanticToken[]; +export function applySemanticTokensDelta(base: SemanticTokens, delta: SemanticTokensDelta, content: string): SemanticTokens; +export function parseFoldingRanges(value: unknown): readonly FoldingRange[]; +export function parseInlayHints(value: unknown): readonly InlayHint[]; +export function parseDocumentSymbolTree(value: unknown): readonly DocumentSymbolTree[]; export interface LanguageData { readonly completion: readonly CompletionItem[]; readonly hover: Hover | null; @@ -283,6 +314,10 @@ export interface LanguageData { readonly 'format-document': import('./workspace-edits.mjs').WorkspaceEdit | null; readonly 'format-range': import('./workspace-edits.mjs').WorkspaceEdit | null; readonly 'code-actions': readonly CodeAction[]; + readonly 'semantic-tokens': SemanticTokens; + readonly 'folding-ranges': readonly FoldingRange[]; + readonly 'inlay-hints': readonly InlayHint[]; + readonly 'document-symbol-tree': readonly DocumentSymbolTree[]; } export type LanguageResult = K extends LanguageFeature ? Omit & {readonly kind: K; readonly data: LanguageData[K]} : never; @@ -290,16 +325,18 @@ export interface LanguageProvider { provide(request: LanguageRequest, options: {readonly signal: AbortSignal}): LanguageResult | Promise>; readonly resolve?: K extends 'completion' ? (request: LanguageRequest<'completion'>, item: CompletionItem, options: {readonly signal: AbortSignal}) => CompletionItem | Promise : K extends 'code-actions' ? (request: LanguageRequest<'code-actions'>, item: CodeAction, options: {readonly signal: AbortSignal}) => CodeAction | Promise : never; + readonly provideDelta?: K extends 'semantic-tokens' ? (request: LanguageRequest<'semantic-tokens'>, previous: PreviousSemanticTokens, options: {readonly signal: AbortSignal}) => SemanticTokensDelta | null | Promise : never; } export interface LanguageRegistry extends Disposable { - register(pluginId: string, selector: ProviderSelector, provider: LanguageProvider, options?: K extends 'completion' ? {readonly resolve?: boolean; readonly snippets?: boolean} : K extends 'code-actions' ? {readonly resolve?: boolean} : never): Disposable; + register(pluginId: string, selector: ProviderSelector, provider: LanguageProvider, options?: K extends 'completion' ? {readonly resolve?: boolean; readonly snippets?: boolean} : K extends 'code-actions' ? {readonly resolve?: boolean} : K extends 'semantic-tokens' ? {readonly semanticDelta?: boolean} : never): Disposable; request(request: LanguageRequest, options?: RequestOptions): Promise>; resolve(token: string, options?: RequestOptions): Promise>; prepareEdit(result: K extends FormattingFeature | 'code-actions' ? LanguageResult : never, itemIndex?: number): import('./workspace-edits.mjs').WorkspaceEdit | null; release(result: LanguageResult): void; + validateResult(result: LanguageResult): LanguageResult; prepareCompletion(result: LanguageResult<'completion'>, itemIndex?: number): CompletionInsertion; releaseCompletion(result: LanguageResult<'completion'>): void; - invalidate(): void; + invalidate(options?: {readonly retainSemantic?: boolean}): void; } export function parseLanguageRequest(value: unknown): LanguageRequest; export function parseLanguageResult(value: unknown): LanguageResult; diff --git a/src/index.mjs b/src/index.mjs index ca17ec7..d0a1ab5 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -9,6 +9,7 @@ export { createLanguageRegistry } from './lifecycle.mjs'; export { LANGUAGE_FEATURES, LANGUAGE_CAPABILITIES, parseLanguageRequest, parseLanguageResult, createLanguageResult } from './contracts.mjs'; export {LANGUAGE_LIMITS, parseSnippet, parseCompletionItem, parseSignatureHelp} from './language-assistance.mjs'; export {CODE_ACTION_KINDS, LANGUAGE_EDIT_LIMITS, parseFormattingOptions, parseCodeAction} from './language-editing.mjs'; +export {SEMANTIC_STYLE_KEYS, LANGUAGE_DISPLAY_LIMITS, parseSemanticLegend, parseSemanticTokens, parseSemanticTokensDelta, validateSemanticTokens, decodeSemanticTokens, applySemanticTokensDelta, parseFoldingRanges, parseInlayHints, parseDocumentSymbolTree} from './language-display.mjs'; export { createPluginHost } from './host.mjs'; /** Declare an ESM plugin. Activation is performed by a host with explicit grants. */ diff --git a/src/language-assistance-schemas.mjs b/src/language-assistance-schemas.mjs index 19113dc..77f5e98 100644 --- a/src/language-assistance-schemas.mjs +++ b/src/language-assistance-schemas.mjs @@ -1,4 +1,5 @@ import {LANGUAGE_LIMITS as limits} from './language-assistance.mjs'; +import {LANGUAGE_DISPLAY_LIMITS} from './language-display.mjs'; import {LANGUAGE_FEATURES} from './contracts.mjs'; const object = (properties, required = Object.keys(properties)) => ({type: 'object', properties, required, additionalProperties: false}); const text = (maxLength, minLength = 0) => ({type: 'string', minLength, maxLength}); @@ -25,5 +26,5 @@ export const LANGUAGE_ASSISTANCE_SCHEMAS = Object.freeze({ 'completion-item': COMPLETION_ITEM_SCHEMA, 'signature-help': SIGNATURE_HELP_SCHEMA, 'language-snippet': {...text(limits.snippetChars), $comment: 'Runtime accepts $n, ${n}, ${n:literal}, one $0 and escapes for backslash/dollar/braces. Indices are 0..99; no variables, nesting, choices or transforms. Mirrored defaults must agree; expansion is <=16384 UTF-16 units and <=128 explicit stops.'}, - 'language-capabilities': object({protocolVersion: {const: 1}, features: {const: LANGUAGE_FEATURES}, completionResolve: {const: true}, codeActionResolve: {const: true}, snippets: {const: true}, positions: {const: 'utf16-zero-based'}, limits: object(Object.fromEntries(Object.entries(limits).map(([key, value]) => [key, {const: value}])))}), + 'language-capabilities': object({protocolVersion: {const: 1}, features: {const: LANGUAGE_FEATURES}, completionResolve: {const: true}, codeActionResolve: {const: true}, semanticTokensDelta: {const: true}, snippets: {const: true}, positions: {const: 'utf16-zero-based'}, limits: object(Object.fromEntries(Object.entries(limits).map(([key, value]) => [key, {const: value}]))), displayLimits: object(Object.fromEntries(Object.entries(LANGUAGE_DISPLAY_LIMITS).map(([key, value]) => [key, {const: value}])))}), }); diff --git a/src/language-display-schemas.mjs b/src/language-display-schemas.mjs new file mode 100644 index 0000000..1c2a428 --- /dev/null +++ b/src/language-display-schemas.mjs @@ -0,0 +1,16 @@ +import {LANGUAGE_DISPLAY_LIMITS as limits, SEMANTIC_STYLE_KEYS, DISPLAY_SYMBOL_KINDS} from './language-display.mjs'; +const object = (properties, required = Object.keys(properties)) => ({type: 'object', properties, required, additionalProperties: false}); +const text = maximum => ({type: 'string', minLength: 1, maxLength: maximum}); +const integer = (maximum, minimum = 0) => ({type: 'integer', minimum, maximum}); +const list = (items, maxItems, minItems = 0) => ({type: 'array', items, maxItems, minItems}); +const position = object({line: integer(262144), character: integer(262144)}), range = object({start: position, end: position}); +const id = {...text(128), pattern: '^[^\\u0000-\\u001f\\u007f]+$'}; +const name = {...text(64), pattern: '^[A-Za-z][A-Za-z0-9_.-]*$'}; +const data = list(integer(0x7fffffff), limits.semanticTokens * 5); +export const SEMANTIC_LEGEND_SCHEMA = {...object({tokenTypes: list(object({name, style: {enum: SEMANTIC_STYLE_KEYS}}), limits.tokenTypes, 1), tokenModifiers: {...list(name, limits.tokenModifiers), uniqueItems: true}}), $comment: 'Runtime enforces unique type names.'}; +export const SEMANTIC_TOKENS_SCHEMA = {...object({resultId: id, legend: SEMANTIC_LEGEND_SCHEMA, data, updateKind: {enum: ['full', 'delta', 'fallback']}}, ['resultId', 'legend', 'data']), $comment: 'Five integers per token. Runtime validates legend references, sorted nonoverlapping UTF-16 ranges and the current document. Host overwrites resultId and updateKind.'}; +export const SEMANTIC_DELTA_SCHEMA = {...object({baseResultId: id, resultId: id, edits: list(object({start: integer(limits.semanticTokens * 5), deleteCount: integer(limits.semanticTokens * 5), data}, ['start', 'deleteCount']), limits.deltaEdits)}), $comment: 'Edits address the original integer array, are ordered and disjoint, and share the total insertion budget. The complete reconstructed result is validated against the new document.'}; +export const FOLDING_SCHEMA = {...list(object({range, kind: {enum: ['region', 'comment', 'imports']}, collapsedText: text(256)}, ['range']), limits.foldingRanges), $comment: 'Runtime rejects empty, duplicate, crossing or unordered ranges and nesting deeper than 32.'}; +export const INLAY_SCHEMA = {...list(object({position, label: {...text(1024), pattern: '^[^\\r\\n\\u0000]+$'}, kind: {enum: ['type', 'parameter']}, paddingLeft: {type: 'boolean'}, paddingRight: {type: 'boolean'}, tooltip: {type: 'string', maxLength: 4096}}, ['position', 'label']), limits.inlayHints), $comment: 'Plain text only. Runtime checks sorted positions within the requested range and document.'}; +export const SYMBOL_TREE_SCHEMA = {...list({$ref: '#/$defs/symbol'}, limits.symbols), $defs: {symbol: object({name: text(256), kind: {enum: DISPLAY_SYMBOL_KINDS}, range, selectionRange: range, detail: text(2048), children: list({$ref: '#/$defs/symbol'}, limits.symbols)}, ['name', 'kind', 'range', 'selectionRange'])}, $comment: 'Runtime limits the entire tree to 1000 symbols and 16 levels, with contained selections/children and ordered nonoverlapping siblings.'}; +export const LANGUAGE_DISPLAY_SCHEMAS = Object.freeze({'semantic-legend': SEMANTIC_LEGEND_SCHEMA, 'semantic-tokens': SEMANTIC_TOKENS_SCHEMA, 'semantic-tokens-delta': SEMANTIC_DELTA_SCHEMA, 'folding-ranges': FOLDING_SCHEMA, 'inlay-hints': INLAY_SCHEMA, 'document-symbol-tree': SYMBOL_TREE_SCHEMA}); diff --git a/src/language-display.mjs b/src/language-display.mjs new file mode 100644 index 0000000..af78e6b --- /dev/null +++ b/src/language-display.mjs @@ -0,0 +1,140 @@ +import {copyWorkspaceJson} from './workspace-values.mjs'; +import {languageCopy, languageObject, languageText, languageInteger, languageList, languageRange, languagePosition, compareLanguagePosition, textIndex, freezeLanguage, invalidLanguage, languageCall} from './language-values.mjs'; +import {ErrorCode, PluginSdkError} from './limits.mjs'; + +export const SEMANTIC_STYLE_KEYS = Object.freeze(['plain', 'keyword', 'type', 'function', 'variable', 'number', 'string', 'comment', 'operator']); +export const LANGUAGE_DISPLAY_LIMITS = Object.freeze({semanticTokens: 20_000, tokenTypes: 64, tokenModifiers: 16, deltaEdits: 64, semanticCacheEntries: 8, semanticCacheBytes: 4_194_304, semanticCacheTtlMs: 60_000, foldingRanges: 1000, foldingDepth: 32, inlayHints: 1000, symbols: 1000, symbolDepth: 16}); +export const DISPLAY_SYMBOL_KINDS = Object.freeze(['module', 'namespace', 'class', 'interface', 'function', 'method', 'variable', 'constant', 'property', 'type']); +const numericLimit = LANGUAGE_DISPLAY_LIMITS.semanticTokens * 5; +const budget = () => {throw new PluginSdkError(ErrorCode.BUDGET_EXCEEDED, 'Language display budget exceeded');}; +const displayCopy = (input, maxDepth = 24) => languageCall(() => copyWorkspaceJson(input, {maxBytes: 1_600_000, maxDepth, maxNodes: 150_000})); +function displayList(value, maximum, minimum = 0) {if (Array.isArray(value) && value.length > maximum) budget(); return languageList(value, maximum, minimum);} +function opaqueId(value) {languageText(value, 128); if (/[\u0000-\u001f\u007f]/.test(value)) invalidLanguage(); return value;} +function legendName(value) {languageText(value, 64); if (!/^[A-Za-z][A-Za-z0-9_.-]*$/.test(value)) invalidLanguage();} +function integerData(value) {for (const item of displayList(value, numericLimit)) languageInteger(item, 0x7fffffff); return value;} +export function parseSemanticLegend(input) { + const value = languageCopy(input, 16_384); languageObject(value, ['tokenTypes', 'tokenModifiers']); + const names = new Set(); + for (const type of displayList(value.tokenTypes, LANGUAGE_DISPLAY_LIMITS.tokenTypes, 1)) { + languageObject(type, ['name', 'style']); legendName(type.name); + if (names.has(type.name) || !SEMANTIC_STYLE_KEYS.includes(type.style)) invalidLanguage(); names.add(type.name); + } + const modifiers = displayList(value.tokenModifiers, LANGUAGE_DISPLAY_LIMITS.tokenModifiers); + for (const name of modifiers) legendName(name); + if (new Set(modifiers).size !== modifiers.length) invalidLanguage(); + return value; +} +const parsedSemantic = new WeakSet(); +export function parseSemanticTokens(input) { + if (parsedSemantic.has(input)) return input; + const value = displayCopy(input); languageObject(value, ['resultId', 'legend', 'data'], ['updateKind']); + opaqueId(value.resultId); parseSemanticLegend(value.legend); integerData(value.data); + if (value.data.length % 5 !== 0 || value.updateKind !== undefined && !['full', 'delta', 'fallback'].includes(value.updateKind)) invalidLanguage(); + parsedSemantic.add(value); return value; +} +export function parseSemanticTokensDelta(input) { + const value = displayCopy(input); languageObject(value, ['baseResultId', 'resultId', 'edits']); opaqueId(value.baseResultId); opaqueId(value.resultId); + let added = 0, end = 0, previousStart = -1; + for (const edit of displayList(value.edits, LANGUAGE_DISPLAY_LIMITS.deltaEdits)) { + languageObject(edit, ['start', 'deleteCount'], ['data']); languageInteger(edit.start, numericLimit); languageInteger(edit.deleteCount, numericLimit); + if (edit.start < end || edit.start === previousStart) invalidLanguage('Semantic delta edits overlap or share an insertion point'); + end = edit.start + edit.deleteCount; previousStart = edit.start; + if (edit.data !== undefined) added += integerData(edit.data).length; + if (added > numericLimit) budget(); + } + return value; +} +function visitSemantic(input, content, collect) { + const value = parseSemanticTokens(input), index = textIndex(content), tokens = []; + let line = 0, character = 0, previousEnd; + for (let n = 0; n < value.data.length; n += 5) { + const [deltaLine, deltaStart, length, type, modifiers] = value.data.slice(n, n + 5); + line += deltaLine; character = deltaLine === 0 ? character + deltaStart : deltaStart; + if (!length || type >= value.legend.tokenTypes.length || modifiers >= 2 ** value.legend.tokenModifiers.length) invalidLanguage('Semantic token legend reference is invalid'); + const range = {start: {line, character}, end: {line, character: character + length}}; index.range(range); + if (previousEnd && compareLanguagePosition(previousEnd, range.start) > 0) invalidLanguage('Semantic tokens overlap'); + previousEnd = range.end; + if (collect) tokens.push({range, type: value.legend.tokenTypes[type].name, style: value.legend.tokenTypes[type].style, modifiers: value.legend.tokenModifiers.filter((_name, bit) => modifiers & 2 ** bit)}); + } + return collect ? freezeLanguage(tokens) : value; +} +export const validateSemanticTokens = (value, content) => visitSemantic(value, content, false); +export const decodeSemanticTokens = (value, content) => visitSemantic(value, content, true); +export function applySemanticTokensDelta(baseInput, deltaInput, content) { + const base = parseSemanticTokens(baseInput), delta = parseSemanticTokensDelta(deltaInput); + if (delta.baseResultId !== base.resultId) throw new PluginSdkError(ErrorCode.STALE_SNAPSHOT, 'Semantic delta base does not match'); + let cursor = 0, length = base.data.length; const parts = []; + for (const edit of delta.edits) { + if (edit.start + edit.deleteCount > base.data.length) invalidLanguage('Semantic delta range exceeds the base'); + length += (edit.data?.length ?? 0) - edit.deleteCount; + parts.push(base.data.slice(cursor, edit.start), edit.data ?? []); cursor = edit.start + edit.deleteCount; + } + if (length > numericLimit) budget(); + parts.push(base.data.slice(cursor)); + const value = parseSemanticTokens({resultId: delta.resultId, legend: base.legend, data: parts.flat()}); + validateSemanticTokens(value, content); return value; +} + +export function parseFoldingRanges(input) { + const values = languageCopy(input), stack = []; let previous; + for (const item of displayList(values, LANGUAGE_DISPLAY_LIMITS.foldingRanges)) { + languageObject(item, ['range'], ['kind', 'collapsedText']); languageRange(item.range); + if (compareLanguagePosition(item.range.start, item.range.end) >= 0) invalidLanguage('Fold range must be nonempty'); + if (item.kind !== undefined && !['region', 'comment', 'imports'].includes(item.kind)) invalidLanguage(); + if (item.collapsedText !== undefined) languageText(item.collapsedText, 256); + if (previous && (compareLanguagePosition(previous.start, item.range.start) > 0 || compareLanguagePosition(previous.start, item.range.start) === 0 && compareLanguagePosition(previous.end, item.range.end) <= 0)) invalidLanguage('Fold ranges must be ordered outer-first'); + while (stack.length && compareLanguagePosition(stack.at(-1).end, item.range.start) <= 0) stack.pop(); + if (stack.length && compareLanguagePosition(item.range.end, stack.at(-1).end) > 0) invalidLanguage('Fold ranges cross'); + if (stack.length >= LANGUAGE_DISPLAY_LIMITS.foldingDepth) budget(); + stack.push(item.range); previous = item.range; + } + return values; +} +export function parseInlayHints(input) { + const values = languageCopy(input); let previous; + for (const item of displayList(values, LANGUAGE_DISPLAY_LIMITS.inlayHints)) { + languageObject(item, ['position', 'label'], ['kind', 'paddingLeft', 'paddingRight', 'tooltip']); languagePosition(item.position); languageText(item.label, 1024); + if (/[\r\n\u0000]/.test(item.label) || item.kind !== undefined && !['type', 'parameter'].includes(item.kind)) invalidLanguage(); + for (const key of ['paddingLeft', 'paddingRight']) if (item[key] !== undefined && typeof item[key] !== 'boolean') invalidLanguage(); + if (item.tooltip !== undefined) languageText(item.tooltip, 4096, true); + if (previous && compareLanguagePosition(previous, item.position) > 0) invalidLanguage('Inlay hints must be sorted'); previous = item.position; + } + return values; +} +export function parseDocumentSymbolTree(input) { + const values = displayCopy(input, 64); let count = 0; + function read(siblings, parent, depth) { + if (depth > LANGUAGE_DISPLAY_LIMITS.symbolDepth) budget(); + let previous; + for (const item of displayList(siblings, LANGUAGE_DISPLAY_LIMITS.symbols)) { + if (++count > LANGUAGE_DISPLAY_LIMITS.symbols) budget(); + languageObject(item, ['name', 'kind', 'range', 'selectionRange'], ['detail', 'children']); + languageText(item.name, 256); if (!DISPLAY_SYMBOL_KINDS.includes(item.kind)) invalidLanguage(); + languageRange(item.range); languageRange(item.selectionRange); + if (item.detail !== undefined) languageText(item.detail, 2048); + if (compareLanguagePosition(item.range.start, item.range.end) >= 0 || compareLanguagePosition(item.range.start, item.selectionRange.start) > 0 || compareLanguagePosition(item.selectionRange.end, item.range.end) > 0) invalidLanguage('Symbol selection must lie within its range'); + if (parent && (compareLanguagePosition(parent.start, item.range.start) > 0 || compareLanguagePosition(item.range.end, parent.end) > 0)) invalidLanguage('Child symbol is outside its parent'); + if (previous && compareLanguagePosition(previous.end, item.range.start) > 0) invalidLanguage('Sibling symbols overlap or are out of order'); + if (item.children !== undefined) { + displayList(item.children, LANGUAGE_DISPLAY_LIMITS.symbols); + if (item.children.length) read(item.children, item.range, depth + 1); + } + previous = item.range; + } + } + read(values, undefined, 1); return values; +} +export function validateLanguageDisplay(request, data) { + if (request.kind === 'semantic-tokens') {validateSemanticTokens(data, request.snapshot.text); return;} + const index = textIndex(request.snapshot.text); + if (request.kind === 'folding-ranges') {for (const item of data) index.range(item.range); return;} + if (request.kind === 'inlay-hints') { + for (const item of data) { + index.offset(item.position); + if (compareLanguagePosition(item.position, request.range.start) < 0 || compareLanguagePosition(item.position, request.range.end) > 0) invalidLanguage('Inlay hint is outside the selected range'); + } + return; + } + function walk(items) {for (const item of items) {index.range(item.range); index.range(item.selectionRange); if (item.children) walk(item.children);}} + walk(data); +} diff --git a/src/language-registry.mjs b/src/language-registry.mjs index 2c01b08..8842a26 100644 --- a/src/language-registry.mjs +++ b/src/language-registry.mjs @@ -1,8 +1,9 @@ import {parseProviderSelector, parseLanguageRequest, parseLanguageResult, assertLanguageResultMatchesRequest, createLanguageResult, LANGUAGE_FEATURES} from './contracts.mjs'; import {LANGUAGE_LIMITS, parseCompletionItem, completionInsertion} from './language-assistance.mjs'; -import {languageObject, languageInteger, invalidLanguage} from './language-values.mjs'; +import {languageObject, languageInteger, invalidLanguage, freezeLanguage} from './language-values.mjs'; import {ErrorCode, LIMITS, PluginSdkError} from './limits.mjs'; import {parseCodeAction, sameCodeActionSelection, validateFormattingEdit} from './language-editing.mjs'; +import {LANGUAGE_DISPLAY_LIMITS as displayLimits, parseSemanticTokensDelta, applySemanticTokensDelta} from './language-display.mjs'; const failure = (code, message) => new PluginSdkError(code, message); const sameRange = (a, b) => ['start', 'end'].every(key => a[key].line === b[key].line && a[key].character === b[key].character); @@ -19,9 +20,9 @@ function providerMethod(provider, name) { /** Trusted host primitive. Plugins remain in-process; grants belong to the owning host. */ export function createLanguageRegistry(kind, {isCurrent = () => true} = {}) { if (!LANGUAGE_FEATURES.includes(kind) || typeof isCurrent !== 'function') invalidLanguage(); - const registrations = new Set(), live = new Set(), tokens = new Map(), results = new WeakMap(); + const registrations = new Set(), live = new Set(), tokens = new Map(), results = new WeakMap(), semantic = new Map(); const resolvable = kind === 'completion' || kind === 'code-actions'; - let disposed = false, sequence = 0, epoch = 0, tokenBytes = 0, resolving = 0; + let disposed = false, sequence = 0, epoch = 0, tokenBytes = 0, resolving = 0, semanticBytes = 0; const open = () => {if (disposed) throw failure(ErrorCode.DISPOSED, 'Language registry is disposed');}; const current = (request, expectedEpoch = epoch) => { open(); @@ -30,23 +31,28 @@ export function createLanguageRegistry(kind, {isCurrent = () => true} = {}) { function removeToken(id) {const entry = tokens.get(id); if (entry) {tokenBytes -= entry.bytes; tokens.delete(id);}} function purgeTokens() {const now = Date.now(); for (const [id, entry] of tokens) if (entry.expiresAt <= now || !entry.provider.active) removeToken(id);} function clearTokens() {tokens.clear(); tokenBytes = 0;} + function removeSemantic(id) {const saved = semantic.get(id); if (saved) {semanticBytes -= saved.bytes; semantic.delete(id);}} + function clearSemantic() {semantic.clear(); semanticBytes = 0;} + function purgeSemantic() {const now = Date.now(); for (const [id, saved] of semantic) if (saved.expiresAt <= now || !saved.entry.active) removeSemantic(id);} function abortEntry(entry, code) {for (const ticket of live) if (ticket.entry === entry) ticket.controller.abort(failure(code, 'Language provider is no longer active'));} function register(pluginId, selector, provider, options = {}) { open(); if (typeof pluginId !== 'string' || !pluginId.length || pluginId.length > 128) invalidLanguage(); - selector = parseProviderSelector(selector); languageObject(options, [], ['resolve', 'snippets']); + selector = parseProviderSelector(selector); languageObject(options, [], ['resolve', 'snippets', 'semanticDelta']); for (const value of Object.values(options)) if (typeof value !== 'boolean') invalidLanguage(); - if (!resolvable && options.resolve || kind !== 'completion' && options.snippets) invalidLanguage(); - const provide = providerMethod(provider, 'provide'), resolve = providerMethod(provider, 'resolve'); + if (!resolvable && options.resolve || kind !== 'completion' && options.snippets || kind !== 'semantic-tokens' && options.semanticDelta) invalidLanguage(); + const provide = providerMethod(provider, 'provide'), resolve = providerMethod(provider, 'resolve'), provideDelta = providerMethod(provider, 'provideDelta'); if (!provide) invalidLanguage('Expected provider data methods'); if (resolve && !options.resolve || options.resolve && !resolve) throw failure(ErrorCode.PERMISSION_DENIED, 'Language resolve needs an explicit capability'); + if (provideDelta && !options.semanticDelta || options.semanticDelta && !provideDelta) throw failure(ErrorCode.PERMISSION_DENIED, 'Semantic delta needs an explicit capability'); if (registrations.size >= LIMITS.maxRegistrations) throw failure(ErrorCode.BUDGET_EXCEEDED, 'Language provider limit exceeded'); - const entry = {pluginId, selector, provider, provide, resolve, snippets: options.snippets === true, sequence: sequence++, active: true}; + const entry = {pluginId, selector, provider, provide, resolve, provideDelta, snippets: options.snippets === true, sequence: sequence++, active: true}; registrations.add(entry); return Object.freeze({dispose() { if (!entry.active) return; entry.active = false; registrations.delete(entry); abortEntry(entry, ErrorCode.DISPOSED); for (const [id, token] of tokens) if (token.provider === entry) removeToken(id); + for (const [id, saved] of semantic) if (saved.entry === entry) removeSemantic(id); }}); } async function invoke(entry, request, operation, options = {}, resolution = false) { @@ -114,16 +120,78 @@ export function createLanguageRegistry(kind, {isCurrent = () => true} = {}) { open(); const saved = results.get(result); if (!saved) return; for (const id of saved.tokenIds) removeToken(id); + if (saved.semanticId) removeSemantic(saved.semanticId); results.delete(result); } + function validateResult(result) { + open(); const saved = results.get(result); + if (!saved) throw failure(ErrorCode.STALE_SNAPSHOT, 'Language result does not belong to this registry'); + current(saved.request, saved.epoch); + if (!saved.entry.active) throw failure(ErrorCode.DISPOSED, 'Language provider is no longer active'); + return result; + } + async function requestSemantic(request, entry, options = {}) { + languageObject(options, [], ['signal', 'timeoutMs']); + const timeoutMs = options.timeoutMs ?? LIMITS.defaultTimeoutMs; + languageInteger(timeoutMs, LIMITS.maxTimeoutMs, 1); + const generation = epoch, deadline = performance.now() + timeoutMs; + const {previousResultId, ...providerFields} = request; + const providerRequest = parseLanguageRequest(providerFields); + const call = operation => { + current(request, generation); + const remaining = Math.ceil(deadline - performance.now()); + if (remaining <= 0) throw failure(ErrorCode.BUDGET_EXCEEDED, 'Language request timed out'); + return invoke(entry, request, operation, {...options, timeoutMs: Math.min(timeoutMs, remaining)}); + }; + purgeSemantic(); + const saved = previousResultId ? semantic.get(previousResultId) : undefined; + const sameBase = saved && saved.entry === entry && saved.scope.projectId === request.scope.projectId && saved.scope.sessionId === request.scope.sessionId && saved.previous.snapshot.uri === request.snapshot.uri && saved.previous.snapshot.languageId === request.snapshot.languageId && saved.previous.snapshot.modelVersion <= request.snapshot.modelVersion; + let data, updateKind = previousResultId ? 'fallback' : 'full'; + if (sameBase && entry.provideDelta) { + // Touch the LRU without extending its bounded lifetime. + semantic.delete(previousResultId); semantic.set(previousResultId, saved); + const raw = await call(options => entry.provideDelta.call(entry.provider, providerRequest, saved.previous, options)); + current(request, generation); + if (raw !== null) { + const delta = parseSemanticTokensDelta(raw); + if (delta.baseResultId === saved.previous.resultId) { + data = applySemanticTokensDelta(saved.tokens, delta, request.snapshot.text); updateKind = 'delta'; + } + } + } + if (!data) { + const raw = await call(options => entry.provide.call(entry.provider, providerRequest, options)); + current(request, generation); + const parsed = parseLanguageResult(raw); assertLanguageResultMatchesRequest(parsed, request); data = parsed.data; + } + if (typeof globalThis.crypto?.randomUUID !== 'function') throw failure(ErrorCode.CAPABILITY_UNAVAILABLE, 'Secure token generation is unavailable'); + const id = globalThis.crypto.randomUUID(), {text: _text, ...snapshot} = request.snapshot; + const tokens = freezeLanguage({resultId: data.resultId, legend: data.legend, data: data.data}); + const previous = freezeLanguage({resultId: data.resultId, snapshot, legend: data.legend, data: data.data}); + const bytes = new TextEncoder().encode(JSON.stringify({previous, scope: request.scope})).byteLength; + if (bytes > displayLimits.semanticCacheBytes) throw failure(ErrorCode.BUDGET_EXCEEDED, 'Semantic retention limit exceeded'); + const result = checkedResult(createLanguageResult(request, {...data, resultId: id, updateKind}), request, entry, false); + purgeSemantic(); + while (semantic.size >= displayLimits.semanticCacheEntries || semanticBytes + bytes > displayLimits.semanticCacheBytes) removeSemantic(semantic.keys().next().value); + semantic.set(id, {entry, scope: request.scope, previous, tokens, bytes, expiresAt: Date.now() + displayLimits.semanticCacheTtlMs}); semanticBytes += bytes; + results.get(result).semanticId = id; + return result; + } return Object.freeze({ register, + validateResult, async request(input, options) { open(); const request = parseLanguageRequest(input); if (request.kind !== kind) invalidLanguage('Language feature does not match registry'); const generation = epoch; current(request, generation); purgeTokens(); const entry = [...registrations].filter(entry => entry.selector.languages.includes(request.snapshot.languageId)).sort((a, b) => b.selector.priority - a.selector.priority || a.sequence - b.sequence)[0]; if (!entry) throw failure(ErrorCode.PROVIDER_UNAVAILABLE, 'No language provider matches the document'); + if (kind === 'semantic-tokens') { + const result = await requestSemantic(request, entry, options); + current(request, generation); + if (!entry.active) throw failure(ErrorCode.DISPOSED, 'Language provider is no longer active'); + return result; + } const raw = await invoke(entry, request, options => entry.provide.call(entry.provider, request, options), options); current(request, generation); return checkedResult(raw, request, entry, true); @@ -169,13 +237,16 @@ export function createLanguageRegistry(kind, {isCurrent = () => true} = {}) { }, releaseCompletion: releaseResult, release: releaseResult, - invalidate() { + invalidate(options = {}) { + languageObject(options, [], ['retainSemantic']); + if (options.retainSemantic !== undefined && typeof options.retainSemantic !== 'boolean') invalidLanguage(); open(); epoch++; clearTokens(); + if (!options.retainSemantic) clearSemantic(); for (const ticket of live) ticket.controller.abort(failure(ErrorCode.STALE_SNAPSHOT, 'Language snapshot is no longer current')); }, dispose() { if (disposed) return; - disposed = true; epoch++; clearTokens(); + disposed = true; epoch++; clearTokens(); clearSemantic(); for (const entry of registrations) {entry.active = false; abortEntry(entry, ErrorCode.DISPOSED);} registrations.clear(); }, diff --git a/src/schemas.mjs b/src/schemas.mjs index 180795c..259221d 100644 --- a/src/schemas.mjs +++ b/src/schemas.mjs @@ -18,6 +18,7 @@ import {LANGUAGE_ASSISTANCE_SCHEMAS, COMPLETION_ITEM_SCHEMA, SIGNATURE_HELP_SCHE import {SDK_VERSION} from './version.mjs'; import {WORKSPACE_EDIT_SCHEMA, WORKSPACE_EDIT_SCHEMAS} from './workspace-edit-schemas.mjs'; import {FORMATTING_OPTIONS_SCHEMA, CODE_ACTION_SCHEMA, CODE_ACTION_CONTEXT_SCHEMA, LANGUAGE_EDITING_SCHEMAS} from './language-editing-schemas.mjs'; +import {LANGUAGE_DISPLAY_SCHEMAS, SEMANTIC_TOKENS_SCHEMA, FOLDING_SCHEMA, INLAY_SCHEMA} from './language-display-schemas.mjs'; const schema = 'https://json-schema.org/draft/2020-12/schema'; const base = `https://github.com/Altifigence/dds-plugin-sdk/blob/v${SDK_VERSION}/schemas/`; @@ -65,6 +66,7 @@ const manifestV2 = object({ manifestV2.allOf = [{if: {properties: {runtime: {const: 'ui'}}}, then: {properties: {permissions: {items: {enum: ['document.read', 'diagnostics.publish', 'language.provide']}}}}}]; manifestV2.allOf.push({if: {properties: {capabilities: {contains: {enum: ['completion-resolve', 'completion-snippets']}}}}, then: {properties: {capabilities: {contains: {const: 'completion'}}}}}); manifestV2.allOf.push({if: {properties: {capabilities: {contains: {const: 'code-action-resolve'}}}}, then: {properties: {capabilities: {contains: {const: 'code-actions'}}}}}); +manifestV2.allOf.push({if: {properties: {capabilities: {contains: {const: 'semantic-tokens-delta'}}}}, then: {properties: {capabilities: {contains: {const: 'semantic-tokens'}}}}}); const commandParameter = object({ name: identifier(128), label: text(128), type: {enum: ['string', 'number', 'boolean']}, required: {type: 'boolean'}, choices: list(text(256), 32, 1, true), @@ -78,6 +80,8 @@ function define(name, body) { const range = object({start: position, end: position}); const location = object({path: {...text(1024), $comment: 'Runtime additionally enforces a relative, traversal-free workspace path.'}, range}); const languageData = { + 'semantic-tokens': SEMANTIC_TOKENS_SCHEMA, 'folding-ranges': FOLDING_SCHEMA, 'inlay-hints': INLAY_SCHEMA, + 'document-symbol-tree': {$ref: base + 'document-symbol-tree.schema.json'}, completion: list(COMPLETION_ITEM_SCHEMA, LIMITS.maxLanguageItems), 'signature-help': SIGNATURE_HELP_SCHEMA, 'prepare-rename': {oneOf: [{type: 'null'}, object({range, placeholder: text(256)})]}, @@ -113,6 +117,7 @@ const historyDisposition = {enum:['live','completed','interrupted','expired']}; const historyCursor = {...text(40),pattern:'^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}:(?:0|[1-9][0-9]{0,2})$'}; const historyItem = object({jobId,commandId:text(128),state:{enum:JOB_STATES},disposition:historyDisposition,startedAt:integer(Number.MAX_SAFE_INTEGER),updatedAt:integer(Number.MAX_SAFE_INTEGER),expiresAt:integer(Number.MAX_SAFE_INTEGER,1),revision:integer(Number.MAX_SAFE_INTEGER,1),attemptOf:{oneOf:[jobId,{type:'null'}]},contentPolicy:{enum:['metadata-only','host-redacted']},artifactCount:integer(JOB_LIMITS.artifacts),snapshotCount:integer(JOB_LIMITS.artifacts),resultAvailability:{enum:['none','source-references','snapshot-references','mixed-references','expired']}}); export const SCHEMAS = Object.freeze({ + ...Object.fromEntries(Object.entries(LANGUAGE_DISPLAY_SCHEMAS).map(([name, body]) => [name, define(name, body)])), ...Object.fromEntries(Object.entries(LANGUAGE_EDITING_SCHEMAS).map(([name, body]) => [name, define(name, body)])), ...Object.fromEntries(Object.entries(WORKSPACE_EDIT_SCHEMAS).map(([name, body]) => [name, define(name, body)])), ...Object.fromEntries(Object.entries(LANGUAGE_ASSISTANCE_SCHEMAS).map(([name, body]) => [name, define(name, body)])), @@ -167,18 +172,19 @@ export const SCHEMAS = Object.freeze({ diagnostics: list(diagnostic, LIMITS.maxDiagnostics), })), 'language-request': define('language-request', {oneOf: LANGUAGE_FEATURES.map(kind => { - const positional = !['document-symbols', 'format-document', 'format-range', 'code-actions'].includes(kind); + const positional = !['document-symbols', 'format-document', 'format-range', 'code-actions', 'semantic-tokens', 'folding-ranges', 'inlay-hints', 'document-symbol-tree'].includes(kind); const formatting = ['format-document', 'format-range'].includes(kind), action = kind === 'code-actions'; const fields = { protocolVersion: {const: 1}, requestId: text(128), scope, snapshot: object({...identity, text: {type: 'string', maxLength: LIMITS.documentBytes, 'x-maxUtf8Bytes': LIMITS.documentBytes}}), kind: {const: kind}, ...(positional ? {position} : {}), ...(kind === 'references' ? {includeDeclaration: {type: 'boolean'}} : {}), ...(['completion', 'signature-help'].includes(kind) ? {context: languageContextSchema(kind)} : {}), ...(kind === 'rename' ? {newName: text(256)} : {}), - ...(formatting || action ? {path: text(1024)} : {}), ...(kind === 'format-range' || action ? {range} : {}), + ...(formatting || action ? {path: text(1024)} : {}), ...(kind === 'format-range' || action || kind === 'inlay-hints' ? {range} : {}), + ...(kind === 'semantic-tokens' ? {previousResultId: {...text(36), pattern: '^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$'}} : {}), ...(formatting ? {formatOptions: FORMATTING_OPTIONS_SCHEMA} : {}), ...(action ? {context: CODE_ACTION_CONTEXT_SCHEMA, diagnosticContext: object({revision: integer(Number.MAX_SAFE_INTEGER), diagnostics: list(diagnostic, LIMITS.maxDiagnostics)})} : {}), }; - return object(fields, Object.keys(fields).filter(key => !['includeDeclaration', 'context'].includes(key))); + return object(fields, Object.keys(fields).filter(key => !['includeDeclaration', 'context', 'previousResultId'].includes(key))); })}), 'language-result': define('language-result', {oneOf: LANGUAGE_FEATURES.map(kind => object({ protocolVersion: {const: 1}, requestId: text(128), scope, snapshot: object(identity), kind: {const: kind}, data: languageData[kind], diff --git a/tests/language-display.test.mjs b/tests/language-display.test.mjs new file mode 100644 index 0000000..16630e1 --- /dev/null +++ b/tests/language-display.test.mjs @@ -0,0 +1,173 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {randomUUID} from 'node:crypto'; +import {createPluginHost, createLanguageRegistry, definePlugin, createLanguageResult, parseLanguageRequest, parseManifest, parseSemanticLegend, parseSemanticTokens, parseSemanticTokensDelta, decodeSemanticTokens, applySemanticTokensDelta, parseFoldingRanges, parseInlayHints, parseDocumentSymbolTree, LANGUAGE_DISPLAY_LIMITS, LIMITS} from '../src/index.mjs'; +import {hasCode, deferred} from './fixtures.mjs'; + +const snapshot = {uri: 'memory:///demo', languageId: 'teaching', modelVersion: 1, workspaceRevision: 'one', text: 'let 😀 = 12;\r\nvalue'}; +const legend = {tokenTypes: [{name: 'keyword', style: 'keyword'}, {name: 'value', style: 'variable'}], tokenModifiers: ['readonly']}; +const data = {resultId: 'provider-1', legend, data: [0, 0, 3, 0, 0, 0, 4, 2, 1, 1, 1, 0, 5, 1, 0]}; +const scope = {projectId: 'project', sessionId: 'session'}; +const request = (kind = 'semantic-tokens', input = {}, doc = snapshot) => ({protocolVersion: 1, requestId: 'display', scope, snapshot: doc, kind, ...input}); +const range = (start, end, line = 0) => ({start: {line, character: start}, end: {line, character: end}}); +const manifest = {manifestVersion: 2, id: 'display', name: 'Display', publisher: 'example', version: '1.0.0', protocolVersion: 1, entry: './plugin.mjs', runtime: 'ui', capabilities: ['semantic-tokens', 'semantic-tokens-delta'], permissions: ['document.read', 'language.provide'], supportedHosts: ['test-host'], license: 'Apache-2.0', source: {visibility: 'open', licenseFile: 'LICENSE'}}; +async function fixture(t, {provide, provideDelta, capabilities = manifest.capabilities} = {}) { + const calls = {full: 0, delta: 0}; const host = createPluginHost({scope, grants: manifest.permissions}); t.after(() => host.dispose()); + let registration; + const plugin = definePlugin({...manifest, capabilities}, context => { + registration = context.registerLanguageProvider('semantic-tokens', {languages: ['teaching']}, { + provide(r, options) {calls.full++; assert.equal(r.previousResultId, undefined); return provide ? provide(r, options) : createLanguageResult(r, data);}, + ...(capabilities.includes('semantic-tokens-delta') ? {provideDelta(r, previous, options) {calls.delta++; assert.equal(r.previousResultId, undefined); assert.equal(previous.snapshot.text, undefined); assert.ok(Object.isFrozen(previous.data)); return provideDelta ? provideDelta(r, previous, options) : {baseResultId: previous.resultId, resultId: 'provider-2', edits: []};}} : {}), + }); + }); + const activation = await host.activate(plugin); host.setDocument(snapshot); + return {host, calls, registration, activation, plugin, request: previous => host.requestLanguage('semantic-tokens', previous ? {previousResultId: previous} : {})}; +} + +test('semantic decoding validates UTF-16, CRLF, legends, ordering and immutable bounded arrays', () => { + const decoded = decodeSemanticTokens(data, snapshot.text); + assert.deepEqual(decoded[1], {range: range(4, 6), type: 'value', style: 'variable', modifiers: ['readonly']}); + assert.deepEqual(decoded[2].range, range(0, 5, 1)); assert.ok(Object.isFrozen(decoded)); + for (const numbers of [[0, 0, 0, 0, 0], [0, 5, 1, 1, 0], [0, 4, 1, 1, 0], [0, 0, 3, 2, 0], [0, 0, 3, 0, 2], [0, 0, 3, 0, 0, 0, 2, 2, 1, 0], [2, 0, 1, 0, 0], [0, 0, 3], [-1, 0, 1, 0, 0], [0, 0, 1.5, 0, 0]]) assert.throws(() => decodeSemanticTokens({...data, data: numbers}, snapshot.text), hasCode('invalid_contract')); + for (const invalid of [{...legend, tokenTypes: [{name: 'url(x)', style: 'keyword'}]}, {...legend, tokenTypes: [{name: 'x', style: 'url(javascript:run)'}]}, {...legend, tokenModifiers: ['a', 'a']}, {...legend, tokenTypes: [legend.tokenTypes[0], legend.tokenTypes[0]]}]) assert.throws(() => parseSemanticLegend(invalid), hasCode('invalid_contract')); + const max = {resultId: 'max', legend, data: Array.from({length: LANGUAGE_DISPLAY_LIMITS.semanticTokens}, (_v, n) => [0, n === 0 ? 0 : 1, 1, 0, 0]).flat()}; + assert.equal(decodeSemanticTokens(max, 'x'.repeat(20000)).length, 20000); + assert.throws(() => parseSemanticTokens({...max, data: [...max.data, 0, 1, 1, 0, 0]}), hasCode('budget_exceeded')); +}); + +test('semantic delta edits address original integer offsets and revalidate the whole result', () => { + const delta = {baseResultId: data.resultId, resultId: 'new', edits: [{start: 2, deleteCount: 1, data: [2]}, {start: 14, deleteCount: 1, data: [1]}]}; + const next = applySemanticTokensDelta(data, delta, snapshot.text); + assert.equal(next.data[2], 2); assert.equal(next.data[14], 1); assert.equal(data.data[2], 3); + assert.throws(() => applySemanticTokensDelta(data, {...delta, baseResultId: 'wrong'}, snapshot.text), hasCode('stale_snapshot')); + for (const edits of [[{start: 2, deleteCount: 2}, {start: 3, deleteCount: 0}], [{start: 0, deleteCount: 0}, {start: 0, deleteCount: 0}], [{start: 0, deleteCount: 0, data: [0.5]}]]) assert.throws(() => parseSemanticTokensDelta({...delta, edits}), hasCode('invalid_contract')); + for (const edits of [[{start: 16, deleteCount: 0}], [{start: 2, deleteCount: 1}], [{start: 2, deleteCount: 1, data: [100]}]]) assert.throws(() => applySemanticTokensDelta(data, {...delta, edits}, snapshot.text), hasCode('invalid_contract')); +}); + +test('full and delta return host handles, preserve edit bases and discard stale paint', async t => { + const {host, request: get, calls} = await fixture(t); + const first = await get(); assert.equal(first.data.updateKind, 'full'); assert.notEqual(first.data.resultId, data.resultId); + assert.equal(host.validateLanguageResult(first), first); + assert.throws(() => host.validateLanguageResult(structuredClone(first)), hasCode('stale_snapshot')); + host.setDocument({...snapshot, modelVersion: 2, workspaceRevision: 'two', text: snapshot.text + '!'}); + assert.throws(() => host.validateLanguageResult(first), hasCode('stale_snapshot')); + const next = await get(first.data.resultId); assert.equal(next.data.updateKind, 'delta'); assert.deepEqual(calls, {full: 1, delta: 1}); + host.releaseLanguageResult(next); assert.throws(() => host.validateLanguageResult(next), hasCode('stale_snapshot')); + assert.equal((await get(next.data.resultId)).data.updateKind, 'fallback'); assert.equal(calls.full, 2); +}); + +test('unknown, expired, evicted and reset semantic bases make one full fallback', async t => { + const {host, request: get, calls} = await fixture(t); + const first = await get(); + for (let n = 0; n < LANGUAGE_DISPLAY_LIMITS.semanticCacheEntries; n++) await get(); + const before = calls.full; assert.equal((await get(first.data.resultId)).data.updateKind, 'fallback'); assert.equal(calls.full, before + 1); assert.equal(calls.delta, 0); + const newest = await get(); const realNow = Date.now; Date.now = () => realNow() + LANGUAGE_DISPLAY_LIMITS.semanticCacheTtlMs + 1; + try {assert.equal((await get(newest.data.resultId)).data.updateKind, 'fallback');} finally {Date.now = realNow;} + const reset = await get(); host.setDocument({...snapshot, workspaceRevision: 'replacement'}); + assert.equal((await get(reset.data.resultId)).data.updateKind, 'fallback'); + assert.equal((await get(randomUUID())).data.updateKind, 'fallback'); +}); + +test('provider null or base mismatch falls back once; malformed deltas and provider errors do not', async t => { + for (const answer of [null, {baseResultId: 'unavailable', resultId: 'new', edits: []}]) { + const {request: get, calls} = await fixture(t, {provideDelta: () => answer}); const first = await get(); + assert.equal((await get(first.data.resultId)).data.updateKind, 'fallback'); assert.deepEqual(calls, {full: 2, delta: 1}); + } + for (const [answer, code] of [[{baseResultId: 'provider-1', resultId: 'new', edits: [{start: 2, deleteCount: 1, data: [999]}]}, 'invalid_contract'], [undefined, 'invalid_contract']]) { + const {request: get, calls} = await fixture(t, {provideDelta: () => answer}); const first = await get(); + await assert.rejects(get(first.data.resultId), hasCode(code)); assert.deepEqual(calls, {full: 1, delta: 1}); + } + const {request: get, calls} = await fixture(t, {provideDelta: () => {throw new Error('private provider detail');}}); const first = await get(); + await assert.rejects(get(first.data.resultId), error => error.code === 'provider_failed' && !error.message.includes('private')); assert.equal(calls.full, 1); +}); + +test('delta provider registration requires the declared feature and modifier', async t => { + assert.throws(() => parseManifest({...manifest, capabilities: ['semantic-tokens-delta']}), hasCode('invalid_contract')); + const registry = createLanguageRegistry('semantic-tokens'); t.after(() => registry.dispose()); + const provide = r => createLanguageResult(r, data); + assert.throws(() => registry.register('example', {languages: ['teaching']}, {provide, provideDelta: () => null}), hasCode('permission_denied')); + assert.throws(() => registry.register('example', {languages: ['teaching']}, {provide}, {semanticDelta: true}), hasCode('permission_denied')); + const {request: get, calls} = await fixture(t, {capabilities: ['semantic-tokens']}); const first = await get(); + assert.equal((await get(first.data.resultId)).data.updateKind, 'fallback'); assert.deepEqual(calls, {full: 2, delta: 0}); +}); + +test('provider changes, deactivation and disposal cannot reuse previous provider data', async t => { + const {host, plugin, activation, request: get, calls} = await fixture(t); const first = await get(); + activation.dispose(); assert.throws(() => host.validateLanguageResult(first), hasCode('disposed')); + await host.activate(plugin); assert.equal((await get(first.data.resultId)).data.updateKind, 'fallback'); assert.equal(calls.delta, 0); + host.dispose(); host.dispose(); await assert.rejects(get(), hasCode('disposed')); + const registry = createLanguageRegistry('semantic-tokens'); t.after(() => registry.dispose()); + const a = registry.register('a', {languages: ['teaching']}, {provide: r => createLanguageResult(r, data), provideDelta: () => {throw Error('wrong provider');}}, {semanticDelta: true}); + const base = await registry.request(request()); + registry.register('b', {languages: ['teaching'], priority: 10}, {provide: r => createLanguageResult(r, {...data, resultId: 'b'})}); + assert.equal((await registry.request(request('semantic-tokens', {previousResultId: base.data.resultId}))).data.updateKind, 'fallback'); + a.dispose(); +}); + +test('pending semantic work honors document changes, cancellation and actual live operation slots', async t => { + const waits = [], started = deferred(); const registry = createLanguageRegistry('semantic-tokens'); t.after(() => registry.dispose()); + registry.register('slow', {languages: ['teaching']}, {provide(r) {const wait = deferred(); waits.push({wait, r}); started.resolve(); return wait.promise;}}); + const operations = Array.from({length: LIMITS.maxPendingRequests}, () => registry.request(request(), {timeoutMs: 5})); + await started.promise; await Promise.all(operations.map(p => assert.rejects(p, hasCode('budget_exceeded')))); + await assert.rejects(registry.request(request()), hasCode('budget_exceeded')); + for (const {wait, r} of waits) wait.resolve(createLanguageResult(r, data)); + await new Promise(resolve => setImmediate(resolve)); + const controller = new AbortController(); const cancelled = registry.request(request(), {signal: controller.signal}); + await new Promise(resolve => setImmediate(resolve)); controller.abort(); await assert.rejects(cancelled, hasCode('cancelled')); + const last = waits.at(-1); last.wait.resolve(createLanguageResult(last.r, data)); + const slow = deferred(), entered = deferred(); const {host, request: get} = await fixture(t, {provideDelta: () => {entered.resolve(); return slow.promise;}}); + const first = await get(), pending = get(first.data.resultId); await entered.promise; + host.setDocument({...snapshot, modelVersion: 2}); await assert.rejects(pending, hasCode('stale_snapshot')); slow.resolve(null); +}); + +test('delta fallback shares the original timeout and never starts a third operation', async t => { + let first = true, now = 0; const entered = deferred(), slow = deferred(); + const {host, request: get, calls} = await fixture(t, { + provide: r => {if (first) {first = false; return createLanguageResult(r, data);} entered.resolve(r); return slow.promise;}, + provideDelta: () => {now += 60; return null;}, + }); + const base = await get(); + const timers = [], timer = globalThis.setTimeout, controller = new AbortController(); + t.mock.method(performance, 'now', () => now); + t.mock.method(globalThis, 'setTimeout', (callback, delay, ...args) => {timers.push(delay); return timer(callback, delay, ...args);}); + const pending = host.requestLanguage('semantic-tokens', {previousResultId: base.data.resultId}, {timeoutMs: 100, signal: controller.signal}); + const r = await entered.promise; controller.abort(); await assert.rejects(pending, hasCode('cancelled')); + assert.deepEqual(timers, [100, 40]); assert.deepEqual(calls, {full: 2, delta: 1}); slow.resolve(createLanguageResult(r, data)); +}); + +test('folding ranges reject crossing, duplicate, unsorted, over-deep and out-of-document geometry', () => { + assert.equal(parseFoldingRanges([{range: range(0, 10)}, {range: range(1, 5)}, {range: range(6, 9)}]).length, 3); + for (const values of [[{range: range(0, 4)}, {range: range(2, 6)}], [{range: range(0, 4)}, {range: range(0, 4)}], [{range: range(2, 5)}, {range: range(0, 10)}], [{range: range(1, 1)}]]) assert.throws(() => parseFoldingRanges(values), hasCode('invalid_contract')); + assert.throws(() => parseFoldingRanges(Array.from({length: 33}, (_v, n) => ({range: range(n, 100 - n)}))), hasCode('budget_exceeded')); + assert.throws(() => createLanguageResult(request('folding-ranges'), [{range: range(0, 99)}]), hasCode('invalid_contract')); +}); + +test('inlay hints are literal bounded labels within the requested UTF-16 range', () => { + const label = '', hints = [{position: {line: 1, character: 2}, label, tooltip: 'text'}]; + assert.equal(createLanguageResult(request('inlay-hints', {range: range(0, 5, 1)}), hints).data[0].label, label); + for (const value of [[{...hints[0], command: 'execute'}], [{...hints[0], label: 'x\ny'}], [{...hints[0], position: {line: 0, character: 5}}]]) assert.throws(() => createLanguageResult(request('inlay-hints', {range: range(0, 5, 1)}), value), hasCode('invalid_contract')); + assert.throws(() => parseInlayHints(Array(1001).fill(hints[0])), hasCode('budget_exceeded')); +}); + +test('symbol trees enforce total size, contained children, selections, sibling order and depth', () => { + const symbol = {name: 'top', kind: 'module', range: range(0, 10), selectionRange: range(0, 3)}, child = {...symbol, name: 'child', range: range(4, 6), selectionRange: range(4, 6)}; + assert.equal(parseDocumentSymbolTree([{...symbol, children: [child]}])[0].children.length, 1); + for (const children of [null, {}, [{...child, range: range(4, 12)}], [child, child]]) assert.throws(() => parseDocumentSymbolTree([{...symbol, children}]), hasCode('invalid_contract')); + let deep = child; for (let n = 0; n < 16; n++) deep = {...symbol, children: [deep]}; + assert.throws(() => parseDocumentSymbolTree([deep]), hasCode('budget_exceeded')); + const siblings = Array.from({length: 1000}, (_v, n) => ({...child, range: range(n, n + 1), selectionRange: range(n, n + 1)})); + assert.throws(() => parseDocumentSymbolTree([{...symbol, range: range(0, 1000), children: siblings}]), hasCode('budget_exceeded')); + assert.throws(() => createLanguageResult(request('document-symbol-tree'), [{...symbol, range: range(0, 100)}]), hasCode('invalid_contract')); +}); + +test('display requests reject irrelevant fields and all displayed results require current host ownership', async t => { + for (const invalid of [request('semantic-tokens', {position: {line: 0, character: 0}}), request('semantic-tokens', {previousResultId: 'provider-1'}), request('folding-ranges', {previousResultId: randomUUID()}), request('inlay-hints'), request('document-symbol-tree', {range: range(0, 1)})]) assert.throws(() => parseLanguageRequest(invalid), hasCode('invalid_contract')); + const host = createPluginHost({grants: manifest.permissions}); t.after(() => host.dispose()); + await host.activate(definePlugin({...manifest, capabilities: ['folding-ranges', 'inlay-hints', 'document-symbol-tree']}, context => { + for (const kind of ['folding-ranges', 'inlay-hints', 'document-symbol-tree']) context.registerLanguageProvider(kind, {languages: ['teaching']}, {provide: r => createLanguageResult(r, [])}); + })); host.setDocument(snapshot); + const values = await Promise.all(['folding-ranges', 'inlay-hints', 'document-symbol-tree'].map(kind => host.requestLanguage(kind, kind === 'inlay-hints' ? {range: range(0, 3)} : {}))); + for (const value of values) assert.equal(host.validateLanguageResult(value), value); + host.setDocument({...snapshot, modelVersion: 2}); + for (const value of values) assert.throws(() => host.validateLanguageResult(value), hasCode('stale_snapshot')); +}); diff --git a/tests/types/language-display.mts b/tests/types/language-display.mts new file mode 100644 index 0000000..cc479e6 --- /dev/null +++ b/tests/types/language-display.mts @@ -0,0 +1,29 @@ +import {createPluginHost, createLanguageRegistry, createLanguageResult, definePlugin, decodeSemanticTokens, applySemanticTokensDelta, parseDocumentSymbolTree, LANGUAGE_DISPLAY_LIMITS, type SemanticTokens, type SemanticTokensDelta, type PreviousSemanticTokens, type LanguageResult, type LanguageProvider} from '@altifigence/dds-plugin-sdk'; +const host = createPluginHost({grants: ['document.read', 'language.provide']}); +const provider: LanguageProvider<'semantic-tokens'> = { + provide(request) {return createLanguageResult(request, {resultId: 'provider', legend: {tokenTypes: [{name: 'keyword', style: 'keyword'}], tokenModifiers: []}, data: [0, 0, 3, 0, 0]});}, + provideDelta(request, previous: PreviousSemanticTokens, {signal}) { + signal.throwIfAborted(); const version: number = previous.snapshot.modelVersion; + // @ts-expect-error Cached previous snapshots do not retain document text. + previous.snapshot.text; + return {baseResultId: previous.resultId, resultId: String(version), edits: [{start: 2, deleteCount: 1, data: [2]}]}; + }, +}; +const registry = createLanguageRegistry('semantic-tokens'); +registry.register('display', {languages: ['teaching']}, provider, {semanticDelta: true}); +registry.invalidate({retainSemantic: true}); +const semantic = await host.requestLanguage('semantic-tokens', {previousResultId: 'host-handle'}); +const accepted: LanguageResult<'semantic-tokens'> = host.validateLanguageResult<'semantic-tokens'>(semantic); +decodeSemanticTokens(accepted.data, 'let value'); host.releaseLanguageResult(semantic); +await host.requestLanguage('inlay-hints', {range: {start: {line: 0, character: 0}, end: {line: 0, character: 3}}}); +await host.requestLanguage('folding-ranges', {}); await host.requestLanguage('document-symbol-tree', {}); +// @ts-expect-error Semantic requests do not take cursor positions. +await host.requestLanguage('semantic-tokens', {position: {line: 0, character: 0}}); +// @ts-expect-error Inlay hints require a visible range. +await host.requestLanguage('inlay-hints', {}); +// @ts-expect-error Only semantic token providers can supply deltas. +const invalid: LanguageProvider<'folding-ranges'> = {provide: request => createLanguageResult(request, []), provideDelta() {return null;}}; +const parsed = parseDocumentSymbolTree([]); const count: number = LANGUAGE_DISPLAY_LIMITS.symbols; +const delta: SemanticTokensDelta = {baseResultId: 'old', resultId: 'new', edits: []}; +const tokens: SemanticTokens = applySemanticTokensDelta(semantic.data, delta, 'let value'); +void definePlugin; void parsed; void count; void tokens; void invalid;