diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 83d19d0..69486c5 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -12,6 +12,11 @@ on: schedule: - cron: "41 3 * * 1" workflow_dispatch: + inputs: + host-ref: + description: 'Optional explicit Lithe host commit to validate native workflows before merge' + required: false + type: string permissions: contents: read @@ -59,6 +64,10 @@ jobs: - name: Test run: node --test sdk/node/test/*.test.mjs + - name: Check TypeScript consumers + run: | + npm ci --prefix sdk/node --ignore-scripts --no-audit --no-fund + npm run --prefix sdk/node test:types sdk-java: name: Java SDK @@ -142,7 +151,14 @@ jobs: run: node scripts/sdk-e2e.mjs - name: Run against Lithe's preview branch - if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + if: github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.host-ref == '') + env: + LITHE_PLUGIN_SDK_JAVA_RUNTIME: ${{ steps.java8.outputs.path }}/bin/java + run: node scripts/sdk-e2e.mjs --ref preview --build-workflows + + - name: Run native workflows against the explicitly selected host revision + if: github.event_name == 'workflow_dispatch' && inputs.host-ref != '' env: LITHE_PLUGIN_SDK_JAVA_RUNTIME: ${{ steps.java8.outputs.path }}/bin/java - run: node scripts/sdk-e2e.mjs --ref preview + HOST_REF: ${{ inputs.host-ref }} + run: node scripts/sdk-e2e.mjs --ref "$HOST_REF" --build-workflows diff --git a/README.md b/README.md index 704f40e..c18003c 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ SDK 的源码都在本仓库的 `sdk/` 下,发布在[本仓库的 Releases](ht | 教程 | 内容 | | --- | --- | | [LSP 插件完整生命周期](guides/lsp-plugin-lifecycle.md) | 从 Lua 模板开始:清单、服务器打包、激活、状态目录、停止、升级与卸载 | +| [原生构建工作流(开发中)](guides/native-build-workflows.md) | Node/Java SDK 的构建执行、精确测试、运行准备,以及未提交源码的真实 Core 验证 | | [构建、运行与测试接入](guides/build-tool-integration.md) | 可用的执行提供者示例、工程后端边界,以及开发中的 API 3 状态 | | [编写调试适配器插件](guides/debug-adapter-plugin.md) | API 4:从最小清单到启动方式、先编译再调试的 `executable`、调试控制台补全,以及预发布与转正 | diff --git a/guides/native-build-workflows.md b/guides/native-build-workflows.md new file mode 100644 index 0000000..969e602 --- /dev/null +++ b/guides/native-build-workflows.md @@ -0,0 +1,95 @@ +# 原生构建、测试与运行准备(开发中) + +插件可以只提供工作区后端,不创建网页。用户从原生工具窗口执行构建、发现与运行测试、准备应用启动;宿主继续管理工作区、取消、输出和应用进程。 + +本文对应尚未发布的 Node/Java SDK 源码,以及 Lithe #1495 的宿主改动。发布的 SDK 0.1.0 资产不包含这些新入口。宿主改动合并后,按仓库规则同步 `contract/v1/`,再一起发布、锁定新 SDK;不要手改契约副本或向旧版本宿主隐瞒 `requires.buildProviders`。 + +## 选择入口 + +| 工作 | Node SDK | Java SDK | 最低 `buildProviders` / 进程协议 | +| --- | --- | --- | --- | +| 执行有界任务 | `backend.build.execute` | `onExecute` | 2 | +| 运行精确测试 | `backend.build.test` | `onTest` | 3 | +| 发现可运行的用例 | `backend.build.discoverTests` | `onDiscoverTests` | 3 | +| 准备应用启动 | `backend.build.prepareRun` | `onPrepareRun` | 4 | +| 准备原生程序调试 | `backend.build.prepareDebug` | `onPrepareDebug` | 5 | + +安装时检查清单的能力版本,启动时 SDK 从宿主提供的版本中选最高的共同版本。没有对应协议时,新方法在进入处理器之前被拒绝;不会改发私有函数或展开成另一条宿主命令。Node 用 `backend.buildVersion`、Java 用 `backend.negotiated().buildVersion()` 查看结果。 + +处理器必须在 `listen` 前注册。模型、依赖和测试发现回复由 SDK 补齐对应 `format` 和 `version: 1`;执行回复是 `{ exitCode, output }`,启动准备回复是完整的可移植计划。Core 继续检查选择器、工作区路径和大小限制。 + +## 所有权与清理 + +插件从其选定的上游引擎读取当前工程、测试和运行事实。发现结果中的每个用例都必须能通过同一套件、用例、工程与目标选择器精确执行;处理器重新核对选择,不把失效用例当成“运行全部”。不要在宿主或 SDK 里再扫描 Java 注解、拼 classpath 或解释 Maven 模型。 + +构建与测试处理器使用调用上下文的 `run` 启动工具,取消或调用结束会结束它拥有的工具树。工具正常返回非零码仍是执行结果;异常属于协议/准备失败。不要启动脱离调用的进程,或用一条私有函数代替这些领域入口。 + +运行准备先等待上游同步和必要构建,再返回 `executable`、原始 `arguments` 数组、工作区相对 `workingDirectory` 和 `environment`。`executable.kind` 只能是 `command`(宿主解析的裸命令名)或 `toolchain`(宿主工具链标识)。处理器不启动长期运行的应用;宿主校验后启动、停止并显示应用输出。构建失败、取消或旧结果失效时不能启动应用。 + +执行/测试请求的后端拒绝由 Core 报告为 `plugin.build.failed`。运行准备的 RPC 错误保留通用 LSP `serverError` 分类,不是带退出码的执行结果,也不使用私有函数的 `pluginErrorCode`。宿主在两种失败下都不能启动应用或改走旧命令。 + +执行、测试、运行与调试准备上下文携带 Core 分配的 `operationID`,进度自动带上它。Node 使用 `context.progress({ message, percentage, output })`;Java 使用 `context.progress(message, percentage, output)`。消息上限 4096 UTF-8 字节,单次输出上限 64 KiB,百分比限制在 0–100;取消或结束后的报告被丢弃。模型、依赖和发现不提供执行进度。 + +状态写入宿主提供的可写目录;安装后的插件包只读。`prepareRun` 的结果不能变成跨重启缓存的 classpath 或命令:宿主保存入口选择器,重启重新准备。 + +## 可运行示例 + +两套示例使用相同的虚构构建引擎,工程事实来自工作区的 `lithe-sample.json`。这是协议教学用的引擎,不是 Maven/Java 迁移成果。 + +- [Node 示例](../sdk/node/examples/sample-native-build/backend/main.mjs)及其[清单](../sdk/node/examples/sample-native-build/plugin.json)。开发时从源码引入 SDK;分发时将 `src/` 放入 `backend/vendor/`,把导入改为 `./vendor/index.mjs`。端到端测试按这种结构打包验证。 +- [Java 示例](../sdk/java/examples/sample-native-build/src/main/java/org/example/samplenativebuild/SampleNativeBuild.java)及其[清单](../sdk/java/examples/sample-native-build/plugin.json)。运行 `mvn -B -ntp -f sdk/java/pom.xml package`,将 `sample-native-build/target/sample-native-build.jar` 放到包内 `backend/`。SDK 已 shade 进同一 JAR。 + +示例工程包含构建命令、精确用例及应用参数,例如: + +```json +{ + "projects": [{ + "name": "app", "path": "app", + "build": ["node", "-e", "console.log('built')"], + "tests": [{ "suite": "Suite", "case": "exact case", + "command": ["node", "-e", "console.log('passed')", "--"] }], + "run": { "command": "node", + "arguments": ["-e", "console.log(process.argv.slice(1))", "--"] } + }] +} +``` + +模型的路径相对构建实例,后续请求的工程/目标选择器相对整个工作区;示例显式转换这两种表示,不把嵌套实例当成根工程。 + +`app` 目录需存在,测试工具需可执行。示例重新读取配置、核对工程/目标和测试,并解析真实路径以拒绝工作区外目录;实际插件应调用其上游工程引擎,而非复制这份虚构引擎。 + +## 验证 + +```sh +node --test sdk/node/test/*.test.mjs +npm ci --prefix sdk/node --ignore-scripts +npm run --prefix sdk/node test:types +mvn -B -ntp -f sdk/java/pom.xml verify +node --test scripts/*.test.mjs + +# 锁定契约对应的兼容场景。 +node scripts/sdk-e2e.mjs + +# 宿主分支已提交,但尚未合并时。 +node scripts/sdk-e2e.mjs --source /path/to/Lithe-IDEA --ref branch --build-workflows + +# 显式使用尚未提交的宿主源码;不可和 --ref 混用。 +node scripts/sdk-e2e.mjs --source /path/to/Lithe-IDEA --working-tree --build-workflows +``` + +`--working-tree` 只把 `rust/`、`shared/` 的暂存、未暂存和未跟踪源码复制到一次性 Core 检出,不修改源仓库,也不复制忽略的构建缓存。该模式报告基线 commit 加工作区改动,不能把输出里的基线 SHA 当成完整已提交源码。 + +`--build-workflows` 显式增加两套新协议场景,供合并前验证;契约镜像同步到包含 `buildProviders: 5` 后,默认验证也自动包含它们。每周的 preview 检查显式运行新场景。新的测试通过真实 Core 规划与解码,覆盖构建非零退出码、精确筛选、运行前构建、准备计划、进度、取消后的工具清理、并行工作区隔离,以及包含 SDK/JAR 的完整包文件不变。它不代替原生界面、真实 Maven/JDT 工程或 Windows 实机验证。 + +若复用预构建 Java 示例,分别设置 `LITHE_PLUGIN_SDK_JAVA_SAMPLE` 与 `LITHE_PLUGIN_SDK_JAVA_NATIVE_SAMPLE`。编译使用 JDK 21,产物面向 Java 8;可用 `LITHE_PLUGIN_SDK_JAVA_RUNTIME` 指定实际运行时。 + +调试适配器、长期服务和跨插件协作有各自的宿主契约;这些处理器不开放跨包私有函数或反向 `lithe/*` 调用。 + + +## 原生程序调试准备 + +运行声明加 `debug: {"adapter":"native"}`,同一插件提供 `native` 适配器;manifest 要求 `buildProviders: 5` 与 `debugAdapters: 2`。适配器声明 launch/executable,使用 program、cwd、args、env 的目标绑定。不要用跨包 ID,也不要在后端返回 DAP 进程、端口或任意调试参数。 + +`prepareDebug` 先完成构建,再返回 `{program, arguments, workingDirectory, environment}`。程序是当前实例内的已有文件,路径从工作区起算;宿主校验路径和可执行权限,再复用现有 DebugAdapter Registry。取消、错误和过期结果都不能启动调试。Node 和 Java 的 sample-native-build 从虚构引擎的 `run.debugProgram` 读取程序,参数使用当前请求的 userArguments;示例的 `example-dap` 是占位适配器命令,真实插件应打包符合其许可的成熟适配器。 + +端到端示例验证两个 SDK 的真实进程、运行前构建、原生调试准备前构建、Core 路径解码、进度及失败不返回目标。它不启动真实调试器,也不代表 Java/JDT、跨插件协作或 Windows 已完成。 diff --git a/scripts/sdk-e2e.mjs b/scripts/sdk-e2e.mjs index 98f859a..800be89 100644 --- a/scripts/sdk-e2e.mjs +++ b/scripts/sdk-e2e.mjs @@ -1,6 +1,7 @@ // Runs the SDK end-to-end scenario in sdk/e2e against Lithe's Rust Core. // // node scripts/sdk-e2e.mjs [--ref ] [--source ] +// node scripts/sdk-e2e.mjs --source --working-tree --build-workflows // // Core lives in the Lithe repository, so the scenario is copied into // rust/lithe-core/tests/ of a disposable Lithe checkout in .artifacts/lithe @@ -15,7 +16,8 @@ // The Rust sample backend is built with Cargo unless // LITHE_PLUGIN_SDK_RUST_SAMPLE names a built sample-backend executable. import { spawn } from 'node:child_process'; -import { access, copyFile, mkdir, readdir } from 'node:fs/promises'; +import { access, copyFile, mkdir, readdir, cp, realpath, readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; import { join, resolve } from 'node:path'; import { parseArgs } from 'node:util'; import { readLock, repository } from './contract.mjs'; @@ -24,6 +26,15 @@ const work = join(repository, '.artifacts', 'lithe'); const scenario = join(repository, 'sdk', 'e2e'); const tests = ['plugin_sdk_node', 'plugin_sdk_java', 'plugin_sdk_rust']; +/** Once the contract mirror includes protocol 6, the current native workflows are mandatory. */ +export function includesNativeWorkflows(schema, requested = false) { + const versions = schema?.properties?.requires?.properties?.buildProviders?.enum; + if (!Array.isArray(versions) || versions.some(version => !Number.isInteger(version) || version < 1)) { + throw new Error('The pinned contract must enumerate buildProviders versions.'); + } + return requested || versions.includes(6); +} + function run(command, args, options = {}) { return new Promise((resolvePromise, reject) => { const child = spawn(command, args, { stdio: 'inherit', ...options }); @@ -35,14 +46,15 @@ function run(command, args, options = {}) { }); } -function output(command, args) { +function output(command, args, trim = true) { return new Promise((resolvePromise, reject) => { - const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'inherit'] }); + const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'inherit'], + timeout: 15000, killSignal: 'SIGKILL', env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' } }); let text = ''; child.stdout.on('data', chunk => { text += chunk; }); child.on('error', reject); child.on('close', code => code === 0 - ? resolvePromise(text.trim()) + ? resolvePromise(trim ? text.trim() : text) : reject(new Error(`${command} ${args.join(' ')} failed (exit ${code})`))); }); } @@ -64,12 +76,37 @@ async function checkoutCore(source, ref) { await run('git', ['-C', work, 'config', 'remote.origin.url', source]); await run('git', ['-C', work, 'fetch', '-q', '--depth', '1', '--filter=blob:none', 'origin', ref]); await run('git', ['-C', work, 'checkout', '-q', '--force', '--detach', 'FETCH_HEAD']); - // Scenario files copied by an earlier run are untracked; drop them so a file - // removed from sdk/e2e does not linger. - await run('git', ['-C', work, 'clean', '-q', '-f', '-d', '--', 'rust/lithe-core/tests']); + // The checkout is disposable. Drop prior scenario/working-tree inputs so a + // removed source cannot linger; ignored Cargo caches are left alone. + await run('git', ['-C', work, 'clean', '-q', '-f', '-d', '--', 'rust', 'shared']); return output('git', ['-C', work, 'rev-parse', 'HEAD']); } +/** Replays only rust/ and shared/ from an explicitly selected local working tree. */ +export async function copyWorkingTree(source, destination) { + const directory = await realpath(source); + const root = await output('git', ['-C', directory, 'rev-parse', '--show-toplevel']); + if (await realpath(root) !== directory) throw new Error('--working-tree source must be the repository root.'); + const patch = await output('git', ['-C', directory, 'diff', '--no-ext-diff', '--no-textconv', '--binary', 'HEAD', '--', 'rust', 'shared'], false); + if (patch) { + await new Promise((resolvePromise, reject) => { + const child = spawn('git', ['-C', destination, 'apply', '--binary', '-'], { + stdio: ['pipe', 'inherit', 'inherit'], timeout: 15000, killSignal: 'SIGKILL' }); + child.on('error', reject); + child.stdin.on('error', reject); + child.on('close', code => code === 0 ? resolvePromise() : reject(new Error('Cannot replay working-tree changes.'))); + child.stdin.end(patch); + }); + } + const files = (await output('git', ['-C', directory, 'ls-files', '--others', '--exclude-standard', '-z', '--', 'rust', 'shared'], false)) + .split('\0').filter(Boolean); + for (const file of files) { + const target = join(destination, file); + await mkdir(resolve(target, '..'), { recursive: true }); + await cp(join(directory, file), target, { verbatimSymlinks: true }); + } +} + async function copyScenario(destination) { for (const entry of await readdir(scenario, { recursive: true, withFileTypes: true })) { if (!entry.isFile()) continue; @@ -100,32 +137,43 @@ async function rustSample() { } async function main() { - const { values } = parseArgs({ options: { ref: { type: 'string' }, source: { type: 'string' } } }); + const { values } = parseArgs({ options: { ref: { type: 'string' }, source: { type: 'string' }, + 'working-tree': { type: 'boolean' }, 'build-workflows': { type: 'boolean' } } }); + if (values['working-tree'] && (!values.source || values.ref)) { + throw new Error('--working-tree requires a local --source and cannot be combined with --ref.'); + } const lock = await readLock(); - const ref = values.ref ?? lock.source.revision; + const schema = JSON.parse(await readFile(join(repository, 'contract/v1/plugin-manifest.schema.json'), 'utf8')); + const buildWorkflows = includesNativeWorkflows(schema, values['build-workflows']); + const ref = values['working-tree'] ? 'HEAD' : values.ref ?? lock.source.revision; const source = values.source ?? `${lock.source.repository}.git`; const java = process.env.LITHE_PLUGIN_SDK_JAVA_RUNTIME || await which('java'); const jar = await javaSample(); + const nativeJar = process.env.LITHE_PLUGIN_SDK_JAVA_NATIVE_SAMPLE + ? resolve(process.env.LITHE_PLUGIN_SDK_JAVA_NATIVE_SAMPLE) + : join(repository, 'sdk/java/examples/sample-native-build/target/sample-native-build.jar'); + if (buildWorkflows) await access(nativeJar); const rust = await rustSample(); const revision = await checkoutCore(source, ref); - console.log(`Running the SDK end-to-end scenario against Lithe ${revision}`); + if (values['working-tree']) await copyWorkingTree(source, work); + console.log(`Running the SDK end-to-end scenario against Lithe ${revision}${values['working-tree'] ? ' plus explicit working-tree changes' : ''}`); await copyScenario(join(work, 'rust/lithe-core/tests')); await run('cargo', ['test', '--locked', '--manifest-path', join(work, 'rust/Cargo.toml'), '-p', 'lithe-core', - ...tests.flatMap(test => ['--test', test])], { + ...[...tests, ...(buildWorkflows ? ['plugin_sdk_build'] : [])].flatMap(test => ['--test', test])], { env: { ...process.env, LITHE_PLUGIN_SDK_ROOT: repository, LITHE_PLUGIN_SDK_NODE: process.execPath, LITHE_PLUGIN_SDK_JAVA: java, LITHE_PLUGIN_SDK_JAVA_SAMPLE: jar, + LITHE_PLUGIN_SDK_JAVA_NATIVE_SAMPLE: nativeJar, LITHE_PLUGIN_SDK_RUST_SAMPLE: rust, }, }); } -main().catch(error => { - console.error(error.message); - process.exitCode = 1; -}); +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch(error => { console.error(error.message); process.exitCode = 1; }); +} diff --git a/scripts/sdk-e2e.test.mjs b/scripts/sdk-e2e.test.mjs new file mode 100644 index 0000000..646214b --- /dev/null +++ b/scripts/sdk-e2e.test.mjs @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { copyWorkingTree, includesNativeWorkflows } from './sdk-e2e.mjs'; + +test('the pinned contract turns native workflow coverage on after protocol 6 is published', () => { + const schema = versions => ({ properties: { requires: { properties: { buildProviders: { enum: versions } } } } }); + assert.equal(includesNativeWorkflows(schema([1])), false); + assert.equal(includesNativeWorkflows(schema([1]), true), true); + assert.equal(includesNativeWorkflows(schema([1, 2, 3, 4])), false); + assert.equal(includesNativeWorkflows(schema([1, 2, 3, 4, 5])), false); + assert.equal(includesNativeWorkflows(schema([1, 2, 3, 4, 5, 6])), true); + assert.throws(() => includesNativeWorkflows({}), /enumerate/); + assert.throws(() => includesNativeWorkflows(schema(['4'])), /enumerate/); +}); + +function git(directory, ...args) { + return execFileSync('git', ['-C', directory, ...args], { timeout: 5000, encoding: 'utf8', + env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' } }); +} + +test('explicit working-tree snapshots include staged, unstaged and untracked inputs without changing the source', { timeout: 20000 }, async (t) => { + const root = await mkdtemp(join(tmpdir(), 'sdk-working-tree-')); + t.after(() => rm(root, { recursive: true, force: true })); + const source = join(root, 'source'); + const destination = join(root, 'destination'); + await mkdir(join(source, 'rust'), { recursive: true }); + await mkdir(join(source, 'shared')); + await writeFile(join(source, '.gitignore'), '/rust/target/\n'); + await writeFile(join(source, 'rust/input.rs'), 'original\n'); + await writeFile(join(source, 'rust/deleted.rs'), 'removed\n'); + await writeFile(join(source, 'shared/fixture.json'), '{}\n'); + await writeFile(join(source, 'outside.txt'), 'original\n'); + git(source, 'init', '-q'); + git(source, 'config', 'core.autocrlf', 'false'); + git(source, 'add', '.'); + git(source, '-c', 'user.name=SDK Test', '-c', 'user.email=sdk@example.invalid', '-c', 'commit.gpgsign=false', + '-c', 'core.hooksPath=/dev/null', 'commit', '-qm', 'fixture'); + git(root, 'clone', '-q', '--no-hardlinks', '-c', 'core.autocrlf=false', source, destination); + await writeFile(join(source, 'rust/input.rs'), 'staged\n'); + git(source, 'add', 'rust/input.rs'); + await writeFile(join(source, 'rust/input.rs'), 'unstaged final\n'); + await rm(join(source, 'rust/deleted.rs')); + await writeFile(join(source, 'shared/new fixture.json'), '{"new":true}\n'); + await writeFile(join(source, 'outside.txt'), 'unrelated\n'); + await mkdir(join(source, 'rust/target')); + await writeFile(join(source, 'rust/target/cache'), 'not source\n'); + const indexBefore = await readFile(join(source, '.git/index')); + const before = git(source, 'status', '--porcelain'); + await copyWorkingTree(source, destination); + assert.equal(await readFile(join(destination, 'rust/input.rs'), 'utf8'), 'unstaged final\n'); + assert.equal(await readFile(join(destination, 'shared/new fixture.json'), 'utf8'), '{"new":true}\n'); + await assert.rejects(readFile(join(destination, 'rust/deleted.rs')), { code: 'ENOENT' }); + await assert.rejects(readFile(join(destination, 'rust/target/cache')), { code: 'ENOENT' }); + assert.equal(await readFile(join(destination, 'outside.txt'), 'utf8'), 'original\n'); + assert.equal(git(source, 'status', '--porcelain'), before); + assert.deepEqual(await readFile(join(source, '.git/index')), indexBefore); + assert.equal(await readFile(join(source, 'rust/input.rs'), 'utf8'), 'unstaged final\n'); + await assert.rejects(copyWorkingTree(join(source, 'rust'), destination), /repository root/); +}); diff --git a/sdk/e2e/plugin_sdk/mod.rs b/sdk/e2e/plugin_sdk/mod.rs index f725c8b..b677dd2 100644 --- a/sdk/e2e/plugin_sdk/mod.rs +++ b/sdk/e2e/plugin_sdk/mod.rs @@ -19,6 +19,7 @@ #![allow(dead_code)] pub mod backend; +pub mod native_build; pub mod vcs; use lithe_core::execute_json; @@ -210,7 +211,7 @@ impl Session { serde_json::from_value(latest["result"]["functions"].clone()).expect("function IDs") } - fn build_request(&mut self, operation: &str, plan: &Value) -> Value { + fn send_build_request(&mut self, operation: &str, plan: &Value) { core( "lsp.buildRequest", json!({ @@ -220,6 +221,10 @@ impl Session { "params": plan["provider"]["params"], }), ); + } + + fn build_request(&mut self, operation: &str, plan: &Value) -> Value { + self.send_build_request(operation, plan); let completed = self.completion(operation); assert!(completed.get("error").is_none(), "{completed}"); completed["result"]["value"].clone() diff --git a/sdk/e2e/plugin_sdk/native_build.rs b/sdk/e2e/plugin_sdk/native_build.rs new file mode 100644 index 0000000..f7a05c9 --- /dev/null +++ b/sdk/e2e/plugin_sdk/native_build.rs @@ -0,0 +1,315 @@ +//! Real SDK processes answer native Core requests without a plugin page or a +//! private function. The fictional engine owns all build/test/run facts. +use super::{core, listing, Fixture, Sample, Session, BUILD_SYSTEM, WAIT}; +use serde_json::{json, Value}; +use std::fs; +use std::time::Instant; + +pub fn run(sample: &Sample) { + let manifest: Value = + serde_json::from_slice(&fs::read(sample.package.join("plugin.json")).unwrap()).unwrap(); + let before = listing(&sample.package); + assert_eq!( + core("plugins.declarative.validate", json!({"manifest":manifest}))["status"], + "ok" + ); + let fixture = Fixture::new(&format!("native-{}", sample.tool)); + let workspace = fixture.workspace(); + let root = "nested"; + let instance = workspace.join(root); + fs::create_dir_all(instance.join("app")).unwrap(); + fs::create_dir_all(instance.join("broken")).unwrap(); + fs::create_dir_all(instance.join("slow")).unwrap(); + let config = json!({"projects":[ + {"name":"app","path":"app","build":[sample.node,"-e","const fs=require('fs'); const n=fs.existsSync('build-count')?Number(fs.readFileSync('build-count','utf8')):0; fs.writeFileSync('build-count',String(n+1)); console.log('native build')"], + "tests":[ + {"suite":"Suite","case":"exact # [case]","command":[sample.node,"-e","console.log(JSON.stringify(process.argv.slice(1)))"]}, + {"suite":"Suite","case":"other","command":[sample.node,"-e","process.exit(7)"]}], + "run":{"command":"node","arguments":["-e","console.log(JSON.stringify(process.argv.slice(1)))"],"debugProgram":"native-program"}}, + {"name":"broken","path":"broken","build":[sample.node,"-e","process.exit(2)"], + "run":{"command":"node","arguments":["-e","process.exit(99)"]}}, + {"name":"slow","path":"slow","build":[sample.node,"-e","require('fs').writeFileSync('started', String(process.pid)); setInterval(()=>{}, 1000)"],"run":{"command":"node","arguments":[],"debugProgram":"native-program"}} + ]}); + fs::write(instance.join("lithe-sample.json"), config.to_string()).unwrap(); + fs::write(instance.join("app/native-program"), "native debugger target fixture").unwrap(); + let launch = core( + "plugins.declarative.languageServerLaunchPlan", + json!({ + "manifest":manifest,"buildSystemID":BUILD_SYSTEM,"request":{ + "pluginDirectory":sample.package,"stateDirectory":fixture.root.join("state"), + "workspaceStateDirectory":fixture.root.join("workspace-state"), + "architecture":if cfg!(target_arch="aarch64") {"arm64"} else {"x86_64"} + }}), + ); + assert_eq!(launch["status"], "ok", "{launch}"); + let arguments = launch["plan"]["arguments"].as_array().unwrap(); + let mut session = Session::start( + manifest["id"].as_str().unwrap(), + &sample.runtime, + arguments, + &workspace, + ); + assert!( + session.functions().is_empty(), + "native workflows must not need private functions" + ); + let build = |command: &str, request: Value| { + core( + command, + json!({"manifest":manifest,"buildSystemID":BUILD_SYSTEM,"request":request}), + ) + }; + let app = json!({"name":"app","path":"nested/app"}); + let plan = build("build.modelPlan", json!({"root":root}))["plan"].clone(); + let result = session.build_request("model", &plan); + let model = build( + "build.decodeModel", + json!({"workspaceRoot":workspace,"root":root,"result":result}), + ); + assert_eq!(model["model"]["targets"][0]["name"], "Main", "{model}"); + let target = &model["model"]["targets"][0]; + assert_eq!(target["path"], "nested/app"); + assert_eq!(target["projectID"], model["model"]["projects"][0]["id"]); + + // A second real backend owns the producer model. Host/Core project its facts + // before the consumer receives them; neither process calls the other. + let mut producer = manifest.clone(); + producer["id"] = json!(format!("{}.source", manifest["id"].as_str().unwrap())); + producer["requires"]["buildModelInputs"] = json!(1); + producer["buildSystems"][0]["id"] = json!("shared-source"); + producer["activationEvents"] = json!(["onBuildSystem:shared-source", "onDebug:native"]); + producer["buildSystems"][0]["modelSharing"] = json!({}); + let mut source_session = Session::start(producer["id"].as_str().unwrap(), &sample.runtime, arguments, &workspace); + let source_plan = core("build.modelPlan", json!({"manifest":producer,"buildSystemID":"shared-source","request":{"root":root}})); + assert_eq!(source_plan["status"], "ok", "{source_plan}"); + let source_reply = source_session.build_request("source-model", &source_plan["plan"]); + let source_model = core("build.decodeModel", json!({"manifest":producer,"buildSystemID":"shared-source", + "request":{"workspaceRoot":workspace,"root":root,"result":source_reply}})); + assert_eq!(source_model["status"], "ok", "{source_model}"); + let mut consumer = manifest.clone(); + consumer["requires"]["buildProviders"] = json!(6); + consumer["requires"]["buildModelInputs"] = json!(1); + consumer["buildSystems"][0]["modelInputs"] = json!([{"pluginID":producer["id"],"buildSystemID":"shared-source"}]); + let resolved = core("build.resolveModelInputs", json!({"manifest":consumer,"buildSystemID":BUILD_SYSTEM,"request":{ + "revision":19,"sources":[{"manifest":producer,"instances":[{ + "buildSystemID":"shared-source","root":root,"state":"ready","model":source_model["model"]}]}]}})); + assert_eq!(resolved["status"], "ok", "{resolved}"); + let snapshot = &resolved["modelInputs"]; + assert!(snapshot["inputs"][0]["instances"][0]["model"].get("tasks").is_none()); + assert!(snapshot["inputs"][0]["instances"][0]["model"].get("run").is_none()); + let input_plan = core("build.modelPlan", json!({"manifest":consumer,"buildSystemID":BUILD_SYSTEM, + "request":{"root":root,"modelInputs":snapshot}})); + assert_eq!(input_plan["status"], "ok", "{input_plan}"); + let input_reply = session.build_request("consumer-model", &input_plan["plan"]); + let input_model = core("build.decodeModel", json!({"manifest":consumer,"buildSystemID":BUILD_SYSTEM, + "request":{"workspaceRoot":workspace,"root":root,"result":input_reply}})); + assert_eq!(input_model["model"]["projects"][0]["properties"]["inputRevision"], "19", "{input_model}"); + assert_eq!(input_model["model"]["projects"][0]["properties"]["inputProjects"], "3", "{input_model}"); + drop(source_session); + + for (project, exit) in [ + (app.clone(), 0), + (json!({"name":"broken","path":"nested/broken"}), 2), + ] { + let plan = build( + "build.taskPlan", + json!({"root":root,"task":"build","project":project}), + )["plan"] + .clone(); + let result = session.build_request(&format!("build-{exit}"), &plan); + assert_eq!(result["exitCode"], exit, "{result}"); + } + assert_eq!( + fs::read_to_string(instance.join("app/build-count")).unwrap(), + "1" + ); + let discovery_plan = build("build.testDiscoveryPlan", json!({"root":root}))["plan"].clone(); + let discovery = session.build_request("discover", &discovery_plan); + let decoded = build( + "build.decodeTestDiscovery", + json!({"root":root,"result":discovery}), + ); + let tests = decoded["discovery"]["tests"] + .as_array() + .expect("decoded native test selectors"); + assert_eq!(tests.len(), 2); + let selected = tests + .iter() + .find(|test| test["case"] == "exact # [case]") + .unwrap(); + let request = json!({"root":root,"project":selected["project"],"suite":selected["suite"], + "case":selected["case"],"userArguments":["a b","x#y"]}); + let test_plan = build("build.testPlan", request)["plan"].clone(); + let tested = session.build_request("test-exact", &test_plan); + assert_eq!(tested["exitCode"], 0, "{tested}"); + assert_eq!(tested["output"], "[\"a b\",\"x#y\"]\n"); + + // An externally removed selector cannot silently widen to all tests. + let mut changed = config.clone(); + changed["projects"][0]["tests"] = json!([]); + fs::write(instance.join("lithe-sample.json"), changed.to_string()).unwrap(); + session.send_build_request("stale-test", &test_plan); + assert_eq!( + session.completion("stale-test")["error"]["code"], + "plugin.build.failed" + ); + fs::write(instance.join("lithe-sample.json"), config.to_string()).unwrap(); + + let prepare = build("build.prepareRunPlan", json!({"root":root,"run":"application","project":app, + "target":{"name":target["name"],"path":target["path"],"tags":target["tags"]},"userArguments":["a b","x#y"]}))["plan"].clone(); + let result = session.build_request("prepare", &prepare); + let decoded = build( + "build.decodeRunPreparation", + json!({"root":root,"workspaceRoot":workspace,"result":result}), + ); + assert_eq!(decoded["status"], "ok", "{decoded}"); + assert_eq!(decoded["launch"]["workingDirectory"], "nested/app"); + assert_eq!( + fs::read_to_string(instance.join("app/build-count")).unwrap(), + "2", + "preparation skipped the upstream build" + ); + assert_eq!( + decoded["launch"]["executable"], + json!({"kind":"command","value":"node"}) + ); + assert_eq!( + decoded["launch"]["arguments"] + .as_array() + .unwrap() + .iter() + .rev() + .take(2) + .cloned() + .collect::>(), + json!(["x#y", "a b"]).as_array().unwrap().clone() + ); + assert!( + session + .events + .iter() + .any(|event| event["operationId"] == "prepare" && event["type"] == "requestProgress"), + "preparation must report its own progress" + ); + let debug = build("build.prepareDebugPlan", json!({"root":root,"run":"application","project":app, + "target":{"name":target["name"],"path":target["path"],"tags":target["tags"]},"userArguments":["a b","x#y"]}))["plan"].clone(); + assert_eq!(debug["provider"]["method"], "lithe/build/prepareDebug"); + let result = session.build_request("prepare-debug", &debug); + let decoded_debug = build("build.decodeDebugPreparation", json!({"root":root,"workspaceRoot":workspace,"run":"application","result":result})); + assert_eq!(decoded_debug["status"], "ok", "{decoded_debug}"); + assert_eq!(decoded_debug["preparation"]["adapter"], "native"); + assert_eq!(decoded_debug["preparation"]["program"], "nested/app/native-program"); + assert_eq!(decoded_debug["preparation"]["arguments"], json!(["a b","x#y"])); + assert_eq!(fs::read_to_string(instance.join("app/build-count")).unwrap(), "3", "debug preparation skipped the upstream build"); + assert!(session.events.iter().any(|event| event["operationId"] == "prepare-debug" && event["type"] == "requestProgress")); + let failed = build( + "build.prepareRunPlan", + json!({"root":root,"run":"application", + "project":{"name":"broken","path":"nested/broken"}, + "target":{"name":"Main","path":"nested/broken","tags":["application"]}}), + )["plan"] + .clone(); + session.send_build_request("failed-preparation", &failed); + let failed = session.completion("failed-preparation"); + // Preparation retains the common LSP error category, rather than an + // execution exit-code result or a private-function pluginErrorCode. + assert_eq!(failed["error"]["code"], "serverError"); + assert!( + failed.get("result").is_none(), + "a failed build returned a launch plan" + ); + + let failed_debug = build("build.prepareDebugPlan", json!({"root":root,"run":"application", + "project":{"name":"broken","path":"nested/broken"},"target":{"name":"Main","path":"nested/broken","tags":["application"]}}))["plan"].clone(); + session.send_build_request("failed-debug-preparation", &failed_debug); + let failed_debug = session.completion("failed-debug-preparation"); + assert_eq!(failed_debug["error"]["code"], "serverError"); + assert!(failed_debug.get("result").is_none()); + + // The second workspace uses the same plugin with different current facts. + // Cancelling one instance must leave the other process and model usable. + let other_workspace = fixture.root.join("other"); + fs::create_dir_all(&other_workspace).unwrap(); + fs::write(other_workspace.join("lithe-sample.json"), json!({"projects":[{"name":"other","path":".","build":[sample.node,"-e","console.log('other workspace')"]}]}).to_string()).unwrap(); + let mut other = Session::start( + manifest["id"].as_str().unwrap(), + &sample.runtime, + arguments, + &other_workspace, + ); + for (operation, command, request) in [ + ("slow-build", "build.taskPlan", json!({"root":root,"task":"build","project":{"name":"slow","path":"nested/slow"}})), + ("slow-debug", "build.prepareDebugPlan", json!({"root":root,"run":"application","project":{"name":"slow","path":"nested/slow"}, + "target":{"name":"Main","path":"nested/slow","tags":["application"]}})), + ] { + let _ = fs::remove_file(instance.join("slow/started")); + let slow = build( + command, + request, + )["plan"] + .clone(); + session.send_build_request(operation, &slow); + let deadline = Instant::now() + WAIT; + let pid = loop { + if let Some(pid) = fs::read_to_string(instance.join("slow/started")) + .ok() + .and_then(|text| text.trim().parse::().ok()) + { + break pid; + } + assert!(Instant::now() < deadline, "native tool never started"); + let events = core( + "lsp.waitEvents", + json!({"sessionId":session.id,"timeoutMilliseconds":50}), + ); + session + .events + .extend(events["events"].as_array().cloned().unwrap_or_default()); + }; + core( + "lsp.cancelOperation", + json!({"sessionId":session.id,"operationId":operation}), + ); + assert_eq!( + session.completion(operation)["error"]["code"], + "requestCancelled" + ); + #[cfg(unix)] + { + let deadline = Instant::now() + WAIT; + while super::is_alive(pid) { + assert!( + Instant::now() < deadline, + "cancelled native tool {pid} survived" + ); + let events = core( + "lsp.waitEvents", + json!({"sessionId":session.id,"timeoutMilliseconds":50}), + ); + session + .events + .extend(events["events"].as_array().cloned().unwrap_or_default()); + } + } + #[cfg(not(unix))] + let _ = pid; + } + let plan = build( + "build.taskPlan", + json!({"root":".","task":"build","project":{"name":"other","path":"."}}), + )["plan"] + .clone(); + assert_eq!( + other.build_request("other-build", &plan)["output"], + "other workspace\n" + ); + other.stop(); + session.stop(); + drop(other); + drop(session); + assert_eq!( + listing(&sample.package), + before, + "native workflows changed the plugin package" + ); +} diff --git a/sdk/e2e/plugin_sdk_build.rs b/sdk/e2e/plugin_sdk_build.rs new file mode 100644 index 0000000..41e21dc --- /dev/null +++ b/sdk/e2e/plugin_sdk_build.rs @@ -0,0 +1,68 @@ +//! Native build workflows require the new host contract. Run explicitly with +//! `sdk-e2e.mjs --build-workflows`; the pinned compatibility lane stays separate. +mod plugin_sdk; + +#[test] +fn node_sdk_serves_native_build_test_and_run_workflows() { + let node = plugin_sdk::node_from_environment("native Node build workflows") + .expect("native workflow checks require Node"); + // Package the SDK next to the entry point, as a distributed plugin does. + let package = plugin_sdk::TemporaryPackage::new( + "node-native", + &[( + "plugin.json", + plugin_sdk::repository_path("sdk/node/examples/sample-native-build/plugin.json"), + )], + ); + let backend = std::fs::read_to_string(plugin_sdk::repository_path( + "sdk/node/examples/sample-native-build/backend/main.mjs", + )) + .unwrap(); + let packaged = backend.replace("../../../src/index.mjs", "./vendor/index.mjs"); + assert_ne!( + packaged, backend, + "the packaged entry must use its vendored SDK" + ); + let vendor = package.root.join("backend/vendor"); + std::fs::create_dir_all(&vendor).unwrap(); + for entry in std::fs::read_dir(plugin_sdk::repository_path("sdk/node/src")).unwrap() { + let entry = entry.unwrap(); + assert!( + entry.file_type().unwrap().is_file(), + "update packaging for SDK subdirectories" + ); + std::fs::copy(entry.path(), vendor.join(entry.file_name())).unwrap(); + } + std::fs::write(package.root.join("backend/main.mjs"), packaged).unwrap(); + plugin_sdk::native_build::run(&plugin_sdk::Sample { + tool: "node", + runtime: node.clone(), + node, + package: package.root.clone(), + }); +} + +#[test] +fn java_sdk_serves_native_build_test_and_run_workflows() { + let node = plugin_sdk::node_from_environment("native Java build workflows") + .expect("native workflow checks require Node"); + let java = std::env::var("LITHE_PLUGIN_SDK_JAVA").expect("native workflow checks require Java"); + let jar = std::env::var_os("LITHE_PLUGIN_SDK_JAVA_NATIVE_SAMPLE") + .expect("build the native Java sample first"); + let package = plugin_sdk::TemporaryPackage::new( + "java-native", + &[ + ( + "plugin.json", + plugin_sdk::repository_path("sdk/java/examples/sample-native-build/plugin.json"), + ), + ("backend/sample-native-build.jar", jar.into()), + ], + ); + plugin_sdk::native_build::run(&plugin_sdk::Sample { + tool: "java", + runtime: java, + node, + package: package.root.clone(), + }); +} diff --git a/sdk/java/README.md b/sdk/java/README.md index e5dcd0a..c9df996 100644 --- a/sdk/java/README.md +++ b/sdk/java/README.md @@ -74,6 +74,10 @@ JAR it starts must hold everything it loads. | --- | --- | | `Backend.create(name, version[, capabilities])` | Creates the backend. `capabilities` are standard LSP server capabilities. `experimental.litheBuild` and `experimental.litheFunctions` are reserved for the SDK. | | `onModel(handler)`, `onDependencies(handler)` | Implement the typed build requests. The SDK stamps `format` and `version`. Core validates the document. `litheBuild` is answered only when a handler exists. | +| `onExecute(handler)` | Protocol 2+: a bounded provider-owned task, returning `{ exitCode, output }`. | +| `onTest(handler)`, `onDiscoverTests(handler)` | Protocol 3+: exact execution and runnable selectors; discovery format/version are stamped. | +| `onPrepareRun(handler)` | Protocol 4: prepare a portable plan; the host owns the application. | +| `negotiated().buildVersion()` | Highest common build version (1–6), or zero; newer methods are refused before their handler runs. | | `function(id, handler)` | Declares a function offered in `initialize`. | | `registerFunction(id, handler)`, `unregisterFunction(id)` | Add or withdraw a function. Called before the `initialize` answer (for example from `onInitialize`), they change the initial list. Later calls send `lithe/function/register` or `lithe/function/unregister`, in order. Both return a `CompletableFuture`. Withdrawing a function revokes its running calls. | | `onInitialize(hook)`, `onShutdown(hook)` | Lifecycle hooks. `onInitialize` receives the `initialize` params and what was negotiated. | @@ -93,10 +97,19 @@ Handlers run on their own threads. Every handler receives a `CallContext`: | `throwIfCancelled()` | Throws `CancelledException` once the call is cancelled. | | `run(command[, options])` | `Processes.run` bound to the call: cancelling or ending the call stops the process. | | `log(message)` | Same as `Backend.log`. | -| `functionId()`, `operationId()` | Functions only. `operationId` is opaque; never derive anything from it. | -| `progress(message, percentage)` | Functions only. Either argument may be `null`. The message is cut to 1024 bytes and the percentage is clamped to 0–100. Reports sent after the call ends are dropped. | +| `functionId()` | Functions only. | +| `operationId()` | Opaque wire identity for functions and bounded build/test/run-preparation calls; null for other requests. | +| `progress(message, percentage[, output])` | Functions use 1024-byte messages. Build/test/run preparation use 4096-byte messages and optional output up to 64 KiB. Percentages are clamped to 0–100. Cancelled or settled calls cannot report. Model/dependency/discovery reports are ignored. | -Results and errors map exactly as in the Node.js SDK: +The new build handlers are under development and are not in a published SDK +release. See [native build workflows](../../guides/native-build-workflows.md) +and the [no-page Java sample](examples/sample-native-build/src/main/java/org/example/samplenativebuild/SampleNativeBuild.java). +Manifest `buildProviders` requirements and the negotiated protocol both apply; +registering a handler never bypasses either boundary. Build tool failure is an +exit-code result; throwing an error aborts preparation. + +Private function results and errors map exactly as in the Node.js SDK; typed +build operations follow their domain contract: - A returned value becomes `result.value`. - `PluginError` becomes `plugin.function.failed` with your message and @@ -154,3 +167,26 @@ mvn -B -f sdk/java/pom.xml verify This builds the SDK and the sample for Java 8 with any newer JDK, then runs the JUnit tests. Add `-Djvm=/path/to/java8/bin/java` to run the tests on a Java 8 runtime, as CI does. + + +### Read-only build model inputs + +Build protocol 6 accepts the host's optional `params.modelInputs` snapshot. +A consumer declares exact `modelInputs` producer slots and requires +`buildProviders: 6` plus `buildModelInputs: 1`. A producer opts into +`modelSharing` and selects any ordinary build option values it wants to share. +The host supplies normalized projects, targets and content roots; tasks, run +plans, settings and unselected option values are excluded. + +Treat `revision` as an opaque workspace snapshot identity. Recompute cached +work when it changes. A slot can be unavailable, and an instance can be loading, +stale or failed; only ready instances carry facts. Do not invoke another plugin +or install/activate a missing producer. Input sharing grants no process or +private-function authority. Version 1 producers cannot consume and re-export +inputs. Core validates the shape and the host revokes in-flight work when its +producer changes. Protocol 1–5 handlers reject requests containing model inputs. + +The two native workflow samples demonstrate independent producer and consumer +backends in the Core end-to-end tests. Their engine is fictional; this does not +migrate Maven, JDT, LLDB or existing plugin business logic. The contract mirror +must be synced from the merged Lithe revision before these changes are released. diff --git a/sdk/java/examples/sample-native-build/plugin.json b/sdk/java/examples/sample-native-build/plugin.json new file mode 100644 index 0000000..aa3f8db --- /dev/null +++ b/sdk/java/examples/sample-native-build/plugin.json @@ -0,0 +1,156 @@ +{ + "schemaVersion": 2, + "id": "org.example.sample-native-build", + "displayName": "Sample Native Build", + "version": "0.1.0", + "pluginAPIVersion": 4, + "requires": { + "buildProviders": 5, + "buildSystems": 1, + "serverProcess": 1, + "debugAdapters": 2 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build", + "debug.launch" + ], + "activationEvents": [ + "onBuildSystem:sample", + "onDebug:native" + ], + "languages": [], + "buildSystems": [ + { + "id": "sample", + "displayName": "Sample", + "detection": { + "markers": [ + "lithe-sample.json" + ], + "nesting": "outermost" + }, + "invocation": { + "executables": [ + { + "kind": "command", + "command": "java" + } + ] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "java", + "minimumVersion": "8", + "entry": "backend/sample-native-build.jar" + }, + "arguments": [], + "executesProjectCode": true, + "mayUseNetwork": false + }, + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "group": "build", + "scopes": [ + "workspace", + "project" + ], + "source": "provider", + "arguments": [] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ] + }, + "sync": { + "watch": [ + "lithe-sample.json" + ] + }, + "test": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ], + "filters": [ + "suite", + "case" + ], + "arguments": [], + "discovery": true + }, + "run": [ + { + "id": "application", + "source": "provider", + "name": "Run Application", + "fromTargets": { + "tags": [ + "application" + ] + }, + "arguments": [], + "debug": { + "adapter": "native" + } + } + ] + } + ], + "debugAdapters": [ + { + "id": "native", + "displayName": "Native DAP", + "requests": [ + "launch" + ], + "selectors": [ + { + "targetKinds": [ + "executable" + ] + } + ], + "configurationTypes": [ + "native" + ], + "configurations": { + "launch": { + "targetBindings": { + "program": "target.path", + "cwd": "target.workingDirectory", + "args": "target.arguments", + "env": "target.environment" + } + } + }, + "launch": { + "process": { + "launch": { + "kind": "systemCommand", + "commands": [ + "example-dap" + ] + } + }, + "transport": "stdio" + } + } + ] +} diff --git a/sdk/java/examples/sample-native-build/pom.xml b/sdk/java/examples/sample-native-build/pom.xml new file mode 100644 index 0000000..1921312 --- /dev/null +++ b/sdk/java/examples/sample-native-build/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + + + io.github.lithe-idea + lithe-plugin-sdk-java-parent + 0.1.0 + ../../pom.xml + + + sample-native-build + Lithe native build workflow sample (Java) + + + + io.github.lithe-idea + lithe-plugin-sdk-java + ${project.version} + + + + + sample-native-build + + + + org.apache.maven.plugins + maven-shade-plugin + + + package + + shade + + + false + + + org.example.samplenativebuild.SampleNativeBuild + + + + + + + + + diff --git a/sdk/java/examples/sample-native-build/src/main/java/org/example/samplenativebuild/SampleNativeBuild.java b/sdk/java/examples/sample-native-build/src/main/java/org/example/samplenativebuild/SampleNativeBuild.java new file mode 100644 index 0000000..4773db1 --- /dev/null +++ b/sdk/java/examples/sample-native-build/src/main/java/org/example/samplenativebuild/SampleNativeBuild.java @@ -0,0 +1,200 @@ +package org.example.samplenativebuild; + +import io.github.litheidea.plugin.sdk.Backend; +import io.github.litheidea.plugin.sdk.CallContext; +import io.github.litheidea.plugin.sdk.Json; +import io.github.litheidea.plugin.sdk.PluginError; +import io.github.litheidea.plugin.sdk.Processes; +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Fictional build engine demonstrating native workflows without a plugin page. */ +public final class SampleNativeBuild { + private final Backend backend = Backend.create("sample-native-build", null); + private volatile Path workspace; + + public static void main(String[] arguments) { new SampleNativeBuild().serve(); } + + private void serve() { + backend.onInitialize((params, negotiated) -> workspace = Paths.get(URI.create(Json.string(params, "rootUri"))).toRealPath()); + backend.onModel((params, context) -> { + List model = new ArrayList<>(); + List targets = new ArrayList<>(); + for (Map project : projects(Json.string(params, "root"))) { + if (project.get("run") instanceof Map) targets.add(object("name", "Main", "path", project.get("modelPath"), + "project", project.get("modelPath"), "tags", Collections.singletonList("application"))); + Map row = object("name", project.get("name"), "path", project.get("modelPath")); + if (params.get("modelInputs") instanceof Map) { + Map inputs = Json.object(params.get("modelInputs")); + int count = 0; + for (Object slot : (List) inputs.get("inputs")) { + for (Object input : (List) Json.object(slot).get("instances")) { + Map instance = Json.object(input); + if ("ready".equals(instance.get("state"))) count += ((List) Json.object(instance.get("model")).get("projects")).size(); + } + } + row.put("properties", object("inputRevision", String.valueOf(inputs.get("revision")), "inputProjects", String.valueOf(count))); + } + model.add(row); + } + return object("projects", model, "targets", targets); + }); + backend.onDependencies((params, context) -> object("dependencies", Collections.emptyList())); + backend.onExecute((params, context) -> { + if (!"build".equals(params.get("task"))) throw new PluginError("sample.task", "Unknown build task."); + return build(select(params), strings(params.get("userArguments")), context); + }); + backend.onDiscoverTests((params, context) -> { + List discovered = new ArrayList<>(); + for (Map project : select(params)) { + for (Map test : tests(project)) discovered.add(object("suite", test.get("suite"), "case", test.get("case"), + "project", object("name", project.get("name"), "path", project.get("path")))); + } + return object("tests", discovered); + }); + backend.onTest(this::test); + backend.onPrepareRun((params, context) -> prepare(params, context, false)); + backend.onPrepareDebug((params, context) -> prepare(params, context, true)); + backend.listen(); + } + + private Object prepare(Map params, CallContext context, boolean debug) throws IOException { + List> selected = select(params); + Map target = Json.object(params.get("target")); + if (selected.size() != 1 || !"application".equals(params.get("run"))) throw new PluginError("sample.target", "Select one application."); + Map project = selected.get(0); + if (!(project.get("run") instanceof Map) || !"Main".equals(target.get("name")) || !project.get("path").equals(target.get("path"))) { + throw new PluginError("sample.target", "The selected application no longer exists."); + } + Map built = build(selected, Collections.emptyList(), context); + if (((Number) built.get("exitCode")).intValue() != 0) throw new PluginError("sample.buildFailed", "Build failed before launch."); + Map run = Json.object(project.get("run")); + if (debug) { + Object program = run.get("debugProgram"); + if (!(program instanceof String)) throw new PluginError("sample.target", "This application has no native debug program."); + String path = project.get("path").equals(".") ? (String) program : project.get("path") + "/" + program; + return object("program", path, "arguments", strings(params.get("userArguments")), + "workingDirectory", project.get("path"), "environment", object()); + } + List arguments = strings(run.get("arguments")); + arguments.addAll(strings(params.get("userArguments"))); + return object("executable", object("kind", "command", "value", run.get("command")), "arguments", arguments, + "workingDirectory", project.get("path"), "environment", object()); + } + + private Map build(List> projects, List arguments, CallContext context) throws IOException { + StringBuilder output = new StringBuilder(); + for (Map project : projects) { + context.progress("Building " + project.get("name"), null); + Processes.Result result = run(project.get("build"), project, arguments, context); + output.append(result.stdout()).append(result.stderr()); + if (result.exitCode() != 0) return object("exitCode", result.exitCode(), "output", output.toString()); + } + return object("exitCode", 0, "output", output.toString()); + } + + private Object test(Map params, CallContext context) throws IOException { + int matched = 0; + StringBuilder output = new StringBuilder(); + for (Map project : select(params)) { + for (Map test : tests(project)) { + if (params.containsKey("suite") && !params.get("suite").equals(test.get("suite"))) continue; + if (params.containsKey("case") && !params.get("case").equals(test.get("case"))) continue; + matched++; + Processes.Result result = run(test.get("command"), project, strings(params.get("userArguments")), context); + output.append(result.stdout()).append(result.stderr()); + if (result.exitCode() != 0) return object("exitCode", result.exitCode(), "output", output.toString()); + } + } + if (matched == 0 && (params.containsKey("suite") || params.containsKey("case"))) throw new PluginError("sample.test", "The selected test no longer exists."); + return object("exitCode", 0, "output", output.toString()); + } + + private Processes.Result run(Object declared, Map project, List arguments, CallContext context) throws IOException { + context.throwIfCancelled(); + List command = strings(declared); + if (command.isEmpty()) throw new PluginError("sample.command", "A tool command must be a nonempty argument array."); + command.addAll(arguments); + Processes.Result result = context.run(command, new Processes.Options().directory(inside(Json.string(project, "path"))) + .onStdout(output -> context.progress(null, null, output)) + .onStderr(output -> context.progress(null, null, output))); + context.throwIfCancelled(); + return result; + } + + private List> select(Map params) throws IOException { + List> all = projects(Json.string(params, "root")); + if (!(params.get("project") instanceof Map)) return all; + Map selected = Json.object(params.get("project")); + List> matches = new ArrayList<>(); + for (Map project : all) { + if (project.get("name").equals(selected.get("name")) && project.get("path").equals(selected.get("path"))) matches.add(project); + } + if (matches.size() != 1) throw new PluginError("sample.project", "The selected project no longer exists."); + return matches; + } + + private List> projects(String root) throws IOException { + Path base = inside(root); + String contents = new String(Files.readAllBytes(base.resolve("lithe-sample.json")), StandardCharsets.UTF_8); + List> result = new ArrayList<>(); + for (Map raw : objects(Json.object(Json.parse(contents)).get("projects"))) { + String declared = Json.string(raw, "path"); + if (declared == null || Paths.get(declared).isAbsolute()) throw new PluginError("sample.path", "A project path must be relative to its instance."); + Path directory = base.resolve(declared).toRealPath(); + if (!directory.startsWith(base)) throw new PluginError("sample.path", "A project is outside its instance."); + // The model is instance-relative; later request selectors are workspace-relative. + String modelPath = base.relativize(directory).toString().replace(java.io.File.separatorChar, '/'); + if (modelPath.isEmpty()) modelPath = "."; + Map project = new LinkedHashMap<>(raw); + project.put("modelPath", modelPath); + String workspacePath = Paths.get(root).resolve(modelPath).normalize().toString().replace(java.io.File.separatorChar, '/'); + project.put("path", workspacePath.isEmpty() ? "." : workspacePath); + result.add(project); + } + return result; + } + + private List> tests(Map project) { + return project.containsKey("tests") ? objects(project.get("tests")) : Collections.emptyList(); + } + + private Path inside(String relative) throws IOException { + if (relative == null || Paths.get(relative).isAbsolute()) throw new PluginError("sample.path", "A workspace-relative directory is required."); + Path path = workspace.resolve(relative).toRealPath(); + if (!path.startsWith(workspace)) throw new PluginError("sample.path", "The directory is outside the workspace."); + return path; + } + + private static List strings(Object value) { + if (!(value instanceof List)) throw new PluginError("sample.config", "A string array is required."); + List result = new ArrayList<>(); + for (Object part : (List) value) { + if (!(part instanceof String)) throw new PluginError("sample.config", "A string array is required."); + result.add((String) part); + } + return result; + } + + private static List> objects(Object value) { + if (!(value instanceof List)) throw new PluginError("sample.config", "A list of objects is required."); + List> result = new ArrayList<>(); + for (Object item : (List) value) result.add(Json.object(item)); + return result; + } + + private static Map object(Object... entries) { + Map result = new LinkedHashMap<>(); + for (int index = 0; index < entries.length; index += 2) result.put((String) entries[index], entries[index + 1]); + return result; + } +} diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java index 4bccb27..ca06223 100644 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java +++ b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java @@ -67,14 +67,14 @@ public interface ShutdownHook { /** Protocols the host agreed to in {@code initialize}. */ public static final class Negotiated { - static final Negotiated NONE = new Negotiated(false, false); + static final Negotiated NONE = new Negotiated(false, 0); private final boolean functions; - private final boolean build; + private final int buildVersion; - Negotiated(boolean functions, boolean build) { + Negotiated(boolean functions, int buildVersion) { this.functions = functions; - this.build = build; + this.buildVersion = buildVersion; } /** Plugin functions, {@code litheFunctions} version 1. */ @@ -82,9 +82,14 @@ public boolean functions() { return functions; } - /** Typed build requests, {@code litheBuild} version 1. */ + /** Whether the host negotiated typed build requests. */ public boolean build() { - return build; + return buildVersion > 0; + } + + /** Selected build protocol version, or zero when unavailable. */ + public int buildVersion() { + return buildVersion; } } @@ -97,6 +102,7 @@ private enum Phase { IDLE, LISTENING, INITIALIZING, READY, SHUTTING_DOWN, EXITED Map formats = new HashMap<>(); formats.put(Protocol.BUILD_MODEL, "litheBuildModel"); formats.put(Protocol.BUILD_DEPENDENCIES, "litheDependencies"); + formats.put(Protocol.BUILD_DISCOVER_TESTS, "litheTestDiscovery"); BUILD_FORMATS = Collections.unmodifiableMap(formats); } @@ -187,6 +193,31 @@ public Backend onDependencies(Handler handler) { return declareBuild(Protocol.BUILD_DEPENDENCIES, handler); } + /** Executes a bounded provider task; requires build protocol 2 or newer. */ + public Backend onExecute(Handler handler) { + return declareBuild(Protocol.BUILD_EXECUTE, handler); + } + + /** Runs exact test selectors; requires build protocol 3 or newer. */ + public Backend onTest(Handler handler) { + return declareBuild(Protocol.BUILD_TEST, handler); + } + + /** Returns the upstream engine's runnable test selectors, with the format stamped. */ + public Backend onDiscoverTests(Handler handler) { + return declareBuild(Protocol.BUILD_DISCOVER_TESTS, handler); + } + + /** Prepares native executable target facts; requires build protocol 5. */ + public Backend onPrepareDebug(Handler handler) { + return declareBuild(Protocol.BUILD_PREPARE_DEBUG, handler); + } + + /** Prepares a portable application launch with build protocol 4 or newer. */ + public Backend onPrepareRun(Handler handler) { + return declareBuild(Protocol.BUILD_PREPARE_RUN, handler); + } + /** * Declares a plugin function offered from the start, in the {@code initialize} * answer. To add one later, use {@link #registerFunction}. @@ -457,7 +488,7 @@ private void handleRequest(Object id, String method, Object params) { Handler build; RequestHandler handler; synchronized (lock) { - build = negotiated.build() ? buildHandlers.get(method) : null; + build = negotiated.buildVersion() >= Protocol.buildMethodVersion(method) ? buildHandlers.get(method) : null; handler = requestHandlers.get(method); } if (build != null) { @@ -466,7 +497,7 @@ private void handleRequest(Object id, String method, Object params) { return; } Map arguments = Json.object(params); - run(id, null, null, context -> build.handle(arguments, context), result -> buildResult(method, result), null); + invokeBuild(id, method, arguments, build); return; } if (handler != null) { @@ -522,9 +553,17 @@ private void initialize(Object id, Map params) { phase = Phase.INITIALIZING; initializeParams = params; Map offered = experimental(params.get("capabilities")); + int buildVersion = 0; + if (!buildHandlers.isEmpty()) { + for (int version = Protocol.BUILD_VERSION; version > 0; version--) { + if (offers(offered.get("litheBuild"), version)) { + buildVersion = version; + break; + } + } + } agreed = new Negotiated( - offers(offered.get("litheFunctions"), Protocol.FUNCTIONS_VERSION), - !buildHandlers.isEmpty() && offers(offered.get("litheBuild"), Protocol.BUILD_VERSION)); + offers(offered.get("litheFunctions"), Protocol.FUNCTIONS_VERSION), buildVersion); negotiated = agreed; hooks = new ArrayList<>(initializeHooks); } @@ -555,7 +594,7 @@ private void initialize(Object id, Map params) { } Map experimental = new LinkedHashMap<>(experimental(capabilities)); if (agreed.build()) { - experimental.put("litheBuild", singleton("version", Protocol.BUILD_VERSION)); + experimental.put("litheBuild", singleton("version", agreed.buildVersion())); } if (agreed.functions()) { List offered = new ArrayList<>(); @@ -645,6 +684,42 @@ private void invokeFunction(Object id, Object raw) { () -> reporting.set(false), progress); } + private void invokeBuild(Object id, String method, Map arguments, Handler handler) { + if (arguments.containsKey("modelInputs") && negotiated.buildVersion() < 6) { + respond(id, null, error(Protocol.INVALID_PARAMS, "Model inputs require build protocol 6.")); + return; + } + if (!Protocol.buildProgress(method)) { + run(id, null, null, context -> handler.handle(arguments, context), result -> buildResult(method, result), null); + return; + } + Object wireId = arguments.get("operationID"); + if (!(wireId instanceof String)) { + respond(id, null, error(Protocol.INVALID_PARAMS, "A build request needs its wire operationID.")); + return; + } + AtomicBoolean reporting = new AtomicBoolean(true); + CallContext.ProgressSink progress = new CallContext.ProgressSink() { + @Override + public void report(String message, Double percentage) { report(message, percentage, null); } + @Override + public void report(String text, Double percentage, String output) { + if (!reporting.get()) return; + Map report = singleton("operationID", wireId); + if (text != null) report.put("message", Protocol.boundedText(text, Protocol.MAX_BUILD_MESSAGE_BYTES)); + if (output != null) report.put("output", Protocol.boundedText(output, Protocol.MAX_BUILD_OUTPUT_BYTES)); + if (percentage != null && !percentage.isNaN() && !percentage.isInfinite()) { + report.put("percentage", Math.min(100L, Math.max(0L, Math.round(percentage)))); + } + Map notification = message(null, Protocol.BUILD_PROGRESS); + notification.put("params", report); + write(notification); + } + }; + run(id, null, (String) wireId, context -> handler.handle(arguments, context), + result -> buildResult(method, result), () -> reporting.set(false), progress); + } + private interface Invocation { Object invoke(CallContext context) throws Exception; } @@ -920,23 +995,68 @@ static Map toResponseError(Throwable failure, Cancellation cance static Object buildResult(String method, Object result) { String expected = BUILD_FORMATS.get(method); if (!(result instanceof Map)) { - throw new PluginSdkError("sdk.build.invalidResult", method + " must return a " + expected + " object."); + throw new PluginSdkError("sdk.build.invalidResult", method + " must return a reply object."); } Map document = new LinkedHashMap<>(Json.object(result)); - Object format = document.get("format"); - if (format != null && !expected.equals(format)) { - throw new PluginSdkError("sdk.build.invalidResult", - method + " returned format " + format + ", not " + expected + "."); + if (expected != null) { + Object format = document.get("format"); + if (format != null && !expected.equals(format)) { + throw new PluginSdkError("sdk.build.invalidResult", method + " returned another format."); + } + Object version = document.get("version"); + if (version != null && !(version instanceof Number && ((Number) version).doubleValue() == 1)) { + throw new PluginSdkError("sdk.build.invalidResult", method + " returned a version other than 1."); + } + document.put("format", expected); + document.put("version", 1); + } + if (Protocol.BUILD_EXECUTE.equals(method) || Protocol.BUILD_TEST.equals(method)) { + Object exit = document.get("exitCode"); + Object output = document.get("output"); + if (!(exit instanceof Number) || ((Number) exit).doubleValue() < Integer.MIN_VALUE + || ((Number) exit).doubleValue() > Integer.MAX_VALUE + || ((Number) exit).doubleValue() != ((Number) exit).longValue() + || !(output instanceof String) || Protocol.utf8Length((String) output) > Protocol.MAX_BUILD_RESULT_BYTES) { + throw new PluginSdkError("sdk.build.invalidResult", method + " needs an int32 exitCode and bounded output."); + } + document.put("exitCode", ((Number) exit).longValue()); + } + if (Protocol.BUILD_PREPARE_DEBUG.equals(method)) { + if (!(document.get("program") instanceof String) || !(document.get("workingDirectory") instanceof String) + || !stringList(document.get("arguments")) || !stringMap(document.get("environment")) + || document.size() != 4) { + throw new PluginSdkError("sdk.build.invalidResult", "Native debug preparation needs program, arguments, workingDirectory and environment only."); + } + } + if (Protocol.BUILD_PREPARE_RUN.equals(method)) { + Map executable = Json.object(document.get("executable")); + if (!("toolchain".equals(executable.get("kind")) || "command".equals(executable.get("kind"))) + || !(executable.get("value") instanceof String) || !(document.get("workingDirectory") instanceof String) + || !stringList(document.get("arguments")) || !stringMap(document.get("environment"))) { + throw new PluginSdkError("sdk.build.invalidResult", method + " needs a portable launch plan."); + } } - Object version = document.get("version"); - if (version != null && !(version instanceof Number && ((Number) version).doubleValue() == 1)) { - throw new PluginSdkError("sdk.build.invalidResult", method + " returned version " + version + ", not 1."); + // Workspace containment and domain facts remain Core/provider responsibilities. + if (Protocol.utf8Length(Json.stringify(document)) > Protocol.MAX_BUILD_RESULT_BYTES) { + throw new PluginSdkError("sdk.build.invalidResult", method + " exceeds the build-result size limit."); } - document.put("format", expected); - document.put("version", 1); return document; } + private static boolean stringList(Object value) { + if (!(value instanceof List)) return false; + for (Object item : (List) value) if (!(item instanceof String)) return false; + return true; + } + + private static boolean stringMap(Object value) { + if (!(value instanceof Map)) return false; + for (Map.Entry item : ((Map) value).entrySet()) { + if (!(item.getKey() instanceof String) || !(item.getValue() instanceof String)) return false; + } + return true; + } + /** * A function result as the JSON value Core accepts. A value JSON cannot * encode, or one over Core's 1 MiB limit, fails here as a backend bug with a diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CallContext.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CallContext.java index 5193590..4bde425 100644 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CallContext.java +++ b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CallContext.java @@ -5,12 +5,13 @@ /** * What a handler gets besides its params: the call's cancellation, tools that - * end with the call, the backend's log, and progress for plugin functions. + * end with the call, the backend's log, and progress for plugin functions or bounded build operations. */ public final class CallContext { - /** Sends one progress report; absent outside plugin functions. */ + /** Sends one progress report; absent for model, dependency and discovery requests. */ interface ProgressSink { void report(String message, Double percentage); + default void report(String message, Double percentage, String output) { report(message, percentage); } } private final Cancellation cancellation; @@ -66,14 +67,15 @@ public void log(String message) { } /** - * Reports progress of a plugin function. {@code message} is cut to 1024 - * bytes and {@code percentage} rounded into 0-100; either may be null. - * Reports after the call ended, and from build requests, are dropped. + * Reports progress of a function or bounded build call. Function messages + * are cut to 1024 bytes, build messages to 4096; percentages are rounded to + * 0-100. Reports after cancellation or completion are dropped. */ - public void progress(String message, Double percentage) { - if (progress != null) { - progress.report(message, percentage); - } + public void progress(String message, Double percentage) { progress(message, percentage, null); } + + /** Build calls may additionally stream up to 64 KiB of output per report. */ + public void progress(String message, Double percentage, String output) { + if (progress != null && !cancellation.isCancelled()) progress.report(message, percentage, output); } /** The function this call runs, or null for a build or LSP request. */ @@ -81,7 +83,7 @@ public String functionId() { return functionId; } - /** The host's operation ID of a function call, or null. */ + /** The wire operation ID of a function or bounded build call, otherwise null. */ public String operationId() { return operationId; } diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java index 7a7770b..25ca884 100644 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java +++ b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java @@ -13,7 +13,7 @@ public final class Protocol { /** Protocol versions this SDK can answer. */ public static final int FUNCTIONS_VERSION = 1; - public static final int BUILD_VERSION = 1; + public static final int BUILD_VERSION = 6; public static final String INITIALIZE = "initialize"; public static final String INITIALIZED = "initialized"; @@ -26,6 +26,12 @@ public final class Protocol { public static final String FUNCTION_PROGRESS = "lithe/function/progress"; public static final String BUILD_MODEL = "lithe/build/model"; public static final String BUILD_DEPENDENCIES = "lithe/build/dependencies"; + public static final String BUILD_EXECUTE = "lithe/build/execute"; + public static final String BUILD_TEST = "lithe/build/test"; + public static final String BUILD_DISCOVER_TESTS = "lithe/build/discoverTests"; + public static final String BUILD_PREPARE_DEBUG = "lithe/build/prepareDebug"; + public static final String BUILD_PREPARE_RUN = "lithe/build/prepareRun"; + public static final String BUILD_PROGRESS = "lithe/build/progress"; /** JSON-RPC and LSP error codes the SDK answers with. */ public static final int PARSE_ERROR = -32700; @@ -42,6 +48,23 @@ public final class Protocol { public static final int MAX_PLUGIN_ERROR_CODE_BYTES = 64; public static final int MAX_MESSAGE_BYTES = 1024; public static final int MAX_RESULT_BYTES = 1 << 20; + public static final int MAX_BUILD_RESULT_BYTES = 32 * 1024 * 1024; + public static final int MAX_BUILD_MESSAGE_BYTES = 4096; + public static final int MAX_BUILD_OUTPUT_BYTES = 65536; + + /** Minimum negotiated version; unknown methods can never pass admission. */ + static int buildMethodVersion(String method) { + if (BUILD_MODEL.equals(method) || BUILD_DEPENDENCIES.equals(method)) return 1; + if (BUILD_EXECUTE.equals(method)) return 2; + if (BUILD_TEST.equals(method) || BUILD_DISCOVER_TESTS.equals(method)) return 3; + if (BUILD_PREPARE_DEBUG.equals(method)) return 5; + if (BUILD_PREPARE_RUN.equals(method)) return 4; + return Integer.MAX_VALUE; + } + + static boolean buildProgress(String method) { + return BUILD_EXECUTE.equals(method) || BUILD_TEST.equals(method) || BUILD_PREPARE_RUN.equals(method) || BUILD_PREPARE_DEBUG.equals(method); + } private static final Pattern FUNCTION_SEGMENT = Pattern.compile("[a-z][A-Za-z0-9]*"); private static final Pattern PLUGIN_ERROR_CODE = Pattern.compile("[A-Za-z][A-Za-z0-9._-]*"); diff --git a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java b/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java new file mode 100644 index 0000000..e93e24a --- /dev/null +++ b/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java @@ -0,0 +1,222 @@ +package io.github.litheidea.plugin.sdk; + +import static io.github.litheidea.plugin.sdk.Fixtures.map; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +/** Typed build operations use the same version and ownership boundaries as Core. */ +@Timeout(value = 20, unit = TimeUnit.SECONDS) +class BuildTest { + private static final Map LAUNCH = map("executable", map("kind", "toolchain", "value", "project-jdk"), + "arguments", Arrays.asList("Main", "a b"), "workingDirectory", "app", "environment", map()); + + private static Map offered(Object... versions) { + return map("litheBuild", map("versions", Arrays.asList(versions))); + } + + private static Object errorCode(Map response) { + return Json.object(response.get("error")).get("code"); + } + + @Test + void eachVersionAdmitsOnlyItsTypedMethods() { + List methods = Arrays.asList("execute", "test", "discoverTests", "prepareRun", "prepareDebug"); + int[] minimum = {2, 3, 3, 4, 5}; + List> replies = Arrays.asList(map("exitCode", 0, "output", "built\n"), + map("exitCode", 2, "output", "test failed\n"), + map("tests", Arrays.asList(map("suite", "Suite", "case", "exact # [case]"))), LAUNCH, map("program", "app/bin/main", "arguments", Arrays.asList("a b"), "workingDirectory", "app", "environment", map())); + for (int version = 1; version <= 6; version++) { + Backend backend = Backend.create(null, null) + .onExecute((params, context) -> replies.get(0)) + .onTest((params, context) -> replies.get(1)) + .onDiscoverTests((params, context) -> replies.get(2)) + .onPrepareRun((params, context) -> replies.get(3)) + .onPrepareDebug((params, context) -> replies.get(4)); + TestHost host = new TestHost(backend); + try { + Map result = Json.object(host.initialize(offered(version, 99)).get("result")); + Map experimental = Json.object(Json.object(result.get("capabilities")).get("experimental")); + assertEquals((long) version, Json.object(experimental.get("litheBuild")).get("version")); + assertEquals(version, backend.negotiated().buildVersion()); + for (int index = 0; index < methods.size(); index++) { + Map response = host.request("lithe/build/" + methods.get(index), map("operationID", "wire-" + index)); + if (version < minimum[index]) { + assertEquals(-32601L, errorCode(response)); + } else { + // Compare JSON-normalized numeric types, as seen on the wire. + Map expected = Json.object(Json.parse(Json.stringify(replies.get(index)))); + if (index == 2) { expected.put("format", "litheTestDiscovery"); expected.put("version", 1L); } + assertEquals(expected, response.get("result")); + } + } + } finally { host.close(); } + } + } + + @Test + void readOnlyModelInputsRequireVersionSixAndPreserveHostIdentity() { + Map inputs = map("version", 1L, "revision", 17L, "inputs", Arrays.asList( + map("pluginID", "dev.source", "buildSystemID", "source", "state", "unavailable", "instances", Collections.emptyList()))); + for (int version : new int[] {5, 6}) { + AtomicReference seen = new AtomicReference<>(); + Backend backend = Backend.create(null, null).onModel((params, context) -> { + seen.set(params.get("modelInputs")); + return map("projects", Collections.emptyList(), "targets", Collections.emptyList(), "tasks", Collections.emptyList()); + }); + TestHost host = new TestHost(backend); + try { + host.initialize(offered(version)); + Map response = host.request("lithe/build/model", map("modelInputs", inputs)); + if (version == 5) { assertEquals(-32602L, errorCode(response)); assertEquals(null, seen.get()); } + else { assertEquals(inputs, seen.get()); assertTrue(response.containsKey("result")); } + } finally { host.close(); } + } + } + + @Test + void negotiationSelectsOnlyACommonIntegerVersion() { + for (Object[] versions : new Object[][] {{1, 4, 3, 2}, {99, "4", true}, {}}) { + Backend backend = Backend.create(null, null).onExecute((params, context) -> map("exitCode", 0, "output", "")); + TestHost host = new TestHost(backend); + try { + host.initialize(offered(versions)); + assertEquals(versions.length == 4 ? 4 : 0, backend.negotiated().buildVersion()); + } finally { host.close(); } + } + } + + @Test + void progressUsesTheWireIdAndStopsWhenTheCallEnds() throws Exception { + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicReference late = new AtomicReference<>(); + Backend backend = Backend.create(null, null).onPrepareRun((params, context) -> { + assertEquals(params.get("operationID"), context.operationId()); + late.set(context); + context.progress(repeat("😀", 2000), 140.0, repeat("界", 30000)); + entered.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS), "preparation was not released"); + return LAUNCH; + }); + TestHost host = new TestHost(backend); + try { + host.initialize(offered(4)); + CompletableFuture> pending = host.requestAsync("lithe/build/prepareRun", map("operationID", "wire-42"), 42L); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + Map report = Json.object(host.waitFor(message -> Protocol.BUILD_PROGRESS.equals(message.get("method")), "progress").get("params")); + assertEquals("wire-42", report.get("operationID")); + assertEquals(4096, Protocol.utf8Length((String) report.get("message"))); + assertEquals(65535, Protocol.utf8Length((String) report.get("output"))); + assertEquals(100L, report.get("percentage")); + release.countDown(); + assertEquals(LAUNCH, Fixtures.await(pending, "preparation to finish").get("result")); + late.get().progress("late", null, "late output"); + assertEquals(1L, host.messages().stream().filter(message -> Protocol.BUILD_PROGRESS.equals(message.get("method"))).count()); + } finally { release.countDown(); host.close(); } + } + + @Test + void nativeDebugProgressUsesWireIdentityAndStopsAfterCompletion() throws Exception { + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicReference late = new AtomicReference<>(); + Backend backend = Backend.create(null, null).onPrepareDebug((params, context) -> { + assertEquals(params.get("operationID"), context.operationId()); + late.set(context); + context.progress(repeat("😀", 2000), 140.0, repeat("界", 30000)); + entered.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS), "preparation was not released"); + return map("program", "app/bin/main", "arguments", Collections.emptyList(), "workingDirectory", "app", "environment", map()); + }); + TestHost host = new TestHost(backend); + try { + host.initialize(offered(5)); + CompletableFuture> pending = host.requestAsync("lithe/build/prepareDebug", map("operationID", "wire-42"), 42L); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + Map report = Json.object(host.waitFor(message -> Protocol.BUILD_PROGRESS.equals(message.get("method")), "progress").get("params")); + assertEquals("wire-42", report.get("operationID")); + assertEquals(4096, Protocol.utf8Length((String) report.get("message"))); + assertEquals(65535, Protocol.utf8Length((String) report.get("output"))); + assertEquals(100L, report.get("percentage")); + release.countDown(); + assertTrue(Json.object(Fixtures.await(pending, "preparation to finish").get("result")).containsKey("program")); + late.get().progress("late", null, "late output"); + assertEquals(1L, host.messages().stream().filter(message -> Protocol.BUILD_PROGRESS.equals(message.get("method"))).count()); + } finally { release.countDown(); host.close(); } + } + + @Test + void cancellingOneBuildDoesNotCancelAnotherAndDropsCancelledProgress() throws Exception { + CountDownLatch entered = new CountDownLatch(2); + CountDownLatch release = new CountDownLatch(1); + Backend backend = Backend.create(null, null).onExecute((params, context) -> { + entered.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + context.progress("cancelled progress", null); + return map("exitCode", 0, "output", ""); + }).onTest((params, context) -> { + entered.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + return map("exitCode", 3, "output", "independent failure"); + }); + TestHost host = new TestHost(backend); + try { + host.initialize(offered(4)); + CompletableFuture> pending = host.requestAsync("lithe/build/execute", map("operationID", "wire-cancel"), 42L); + CompletableFuture> sibling = host.requestAsync("lithe/build/test", map("operationID", "wire-other"), 43L); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + host.notify("$/cancelRequest", map("id", 42L)); + // The barrier response proves the read loop saw cancellation before + // releasing the first handler; no timing sleep chooses the race. + Map barrier = host.request("custom.barrier", map()); + assertEquals(-32601L, errorCode(barrier)); + release.countDown(); + assertEquals(-32800L, errorCode(Fixtures.await(pending, "cancelled execution"))); + assertEquals(map("exitCode", 3L, "output", "independent failure"), Fixtures.await(sibling, "sibling execution").get("result")); + assertFalse(host.messages().stream().anyMatch(message -> Protocol.BUILD_PROGRESS.equals(message.get("method")))); + } finally { release.countDown(); host.close(); } + } + + @Test + void invalidRepliesAndParametersDoNotEndTheSession() { + AtomicReference reply = new AtomicReference<>(); + Backend backend = Backend.create(null, null).onExecute((params, context) -> reply.get()) + .onDiscoverTests((params, context) -> map("format", "wrong", "tests", Collections.emptyList())) + .onPrepareRun((params, context) -> map("executable", false)); + TestHost host = new TestHost(backend); + try { + host.initialize(offered(4)); + assertEquals(-32602L, errorCode(host.request("lithe/build/execute", map()))); + assertEquals(-32602L, errorCode(host.request("lithe/build/execute", Collections.emptyList()))); + for (Object value : Arrays.asList(map(), map("exitCode", true, "output", ""), + map("exitCode", 2147483648L, "output", ""), map("exitCode", 0, "output", false))) { + reply.set(value); + assertEquals(-32603L, errorCode(host.request("lithe/build/execute", map("operationID", "wire-invalid")))); + } + for (String method : Arrays.asList("discoverTests", "prepareRun")) { + assertEquals(-32603L, errorCode(host.request("lithe/build/" + method, map("operationID", "wire-invalid")))); + } + reply.set(map("exitCode", 7, "output", "tool failure is a result")); + assertEquals(map("exitCode", 7L, "output", "tool failure is a result"), + host.request("lithe/build/execute", map("operationID", "wire-valid")).get("result")); + } finally { host.close(); } + } + + private static String repeat(String text, int count) { + StringBuilder result = new StringBuilder(); + for (int index = 0; index < count; index++) result.append(text); + return result.toString(); + } +} diff --git a/sdk/java/pom.xml b/sdk/java/pom.xml index d4f11ee..07a73ec 100644 --- a/sdk/java/pom.xml +++ b/sdk/java/pom.xml @@ -17,6 +17,7 @@ plugin-sdk examples/sample-build + examples/sample-native-build diff --git a/sdk/node/README.md b/sdk/node/README.md index e0c72e5..b5dd8de 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -119,6 +119,10 @@ package. The SDK has no dependencies. | --- | --- | | `createBackend({ name?, version?, capabilities? })` | Creates the backend. `capabilities` are standard LSP server capabilities; `experimental.litheBuild`, `experimental.litheFunctions`, and `experimental.litheVcs` are reserved for the SDK. | | `backend.build.model(handler)`, `backend.build.dependencies(handler)` | Implements the typed build requests. The SDK stamps `format` and `version`; a handler returning another format or a version other than 1 fails. Core validates the document. `litheBuild` is answered only when a handler exists. | +| `backend.build.execute(handler)` | Protocol 2+: provider-owned bounded task, returning `{ exitCode, output }`. | +| `backend.build.test(handler)`, `backend.build.discoverTests(handler)` | Protocol 3+: exact test execution and runnable selectors; discovery gets `format: "litheTestDiscovery"`, `version: 1`. | +| `backend.build.prepareRun(handler)` | Protocol 4: returns a portable launch plan after upstream synchronization/build; the host owns the application process. | +| `backend.buildVersion` | Highest common negotiated build version (1–6), or `null`; methods newer than that version are refused before their handler runs. | | `backend.vcs(id, handlers, traits?)` | Implements the typed VCS requests; see [A VCS provider](#a-vcs-provider). `litheVcs` is answered only when the host offers it. | | `backend.function(id, handler)` | Declares a function offered in `initialize`. | | `backend.registerFunction(id, handler)`, `backend.unregisterFunction(id)` | Adds or withdraws a function. Called before the `initialize` answer (for example from `onInitialize`), they change the initial list; later they send `lithe/function/register` / `unregister`. Withdrawing revokes running calls. | @@ -139,11 +143,28 @@ Every handler receives a context: | `throwIfCancelled()` | Throws the cancellation if `signal` is aborted. | | `run(command, args, options)` | `runProcess` bound to the call: cancelling or ending the call ends the whole process tree. In VCS handlers it also reports the finished command unless `options.report` is `false`. | | `log(...)` | Same as `backend.log`. | -| `functionID`, `operationID` | Functions only. `operationID` is opaque; never derive anything from it. | -| `progress({ message?, percentage? })` | Functions only. The message is cut to 1024 bytes and the percentage clamped to 0–100; reports after the call ends are dropped. | +| `functionID` | Functions only. | +| `operationID` | Functions and bounded build/test/run-preparation calls. Opaque wire identity chosen by Core; never derive anything from it. | +| `progress({ message?, percentage?, output? })` | Functions use messages up to 1024 UTF-8 bytes. Build/test/run preparation use messages up to 4096 bytes and output up to 64 KiB per report. Percentages are clamped to 0–100; cancelled or settled build calls cannot report. Model/dependency/discovery contexts have no progress. | + +## Native build workflows (development) + +The new typed handlers are source changes, not a published SDK release. They +require the corresponding host capability (`buildProviders` 2/3/4); the manifest +requirement and runtime negotiation are both checked. A version 1 agreement +never permits execute/test/prepareRun, even when those handlers are registered. + +See [the native workflow guide](../../guides/native-build-workflows.md) and the +[no-page sample](examples/sample-native-build/backend/main.mjs). Core validates +workspace containment and upstream selections; the SDK does not implement a +second build engine. Build tool failures return their exit code, while a thrown +error fails preparation and cannot start an application. ## Results and errors +The following mapping describes private functions. Typed build operations use +their domain result/error contract; see the native workflow guide above. + | Handler outcome | What Lithe sees | | --- | --- | | Returns a value (including `null`) | `result.value` | @@ -200,7 +221,34 @@ lifecycle unified. The SDK enforces the ones it can. ```bash npm test --prefix sdk/node + +# Development-only TypeScript compiler; no production dependencies are added. +npm ci --prefix sdk/node --ignore-scripts +npm run --prefix sdk/node test:types ``` The tests use a bounded in-memory host (`test/harness.mjs`), which plugin authors can copy to test their own backends without starting Lithe. + + +### Read-only build model inputs + +Build protocol 6 accepts the host's optional `params.modelInputs` snapshot. +A consumer declares exact `modelInputs` producer slots and requires +`buildProviders: 6` plus `buildModelInputs: 1`. A producer opts into +`modelSharing` and selects any ordinary build option values it wants to share. +The host supplies normalized projects, targets and content roots; tasks, run +plans, settings and unselected option values are excluded. + +Treat `revision` as an opaque workspace snapshot identity. Recompute cached +work when it changes. A slot can be unavailable, and an instance can be loading, +stale or failed; only ready instances carry facts. Do not invoke another plugin +or install/activate a missing producer. Input sharing grants no process or +private-function authority. Version 1 producers cannot consume and re-export +inputs. Core validates the shape and the host revokes in-flight work when its +producer changes. Protocol 1–5 handlers reject requests containing model inputs. + +The two native workflow samples demonstrate independent producer and consumer +backends in the Core end-to-end tests. Their engine is fictional; this does not +migrate Maven, JDT, LLDB or existing plugin business logic. The contract mirror +must be synced from the merged Lithe revision before these changes are released. diff --git a/sdk/node/examples/sample-native-build/backend/main.mjs b/sdk/node/examples/sample-native-build/backend/main.mjs new file mode 100644 index 0000000..55ecd67 --- /dev/null +++ b/sdk/node/examples/sample-native-build/backend/main.mjs @@ -0,0 +1,121 @@ +// A fictional build engine whose facts are supplied by lithe-sample.json. +// Native IDE operations use typed handlers; this plugin needs no web page. +import { readFile, realpath } from 'node:fs/promises'; +import { join, isAbsolute, resolve, sep, relative, posix } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createBackend, PluginError } from '../../../src/index.mjs'; + +const backend = createBackend({ name: 'sample-native-build' }); +let workspace; +backend.onInitialize(async ({ params }) => { workspace = await realpath(fileURLToPath(params.rootUri)); }); + +backend.build.model(async ({ root, modelInputs }) => { + const all = await projects(root); + return { projects: all.map(({ name, modelPath: path }) => ({ name, path, ...(modelInputs ? { properties: { inputRevision: String(modelInputs.revision), inputProjects: String(modelInputs.inputs.flatMap((slot) => slot.instances).filter((input) => input.state === "ready").flatMap((input) => input.model.projects).length) } } : {}) })), + targets: all.filter(({ run }) => run).map(({ modelPath: path }) => ({ name: 'Main', path, project: path, tags: ['application'] })) }; +}); +backend.build.dependencies(() => ({ dependencies: [] })); + +backend.build.execute(async (params, context) => { + if (params.task !== 'build') throw new PluginError('sample.task', 'Unknown build task.'); + return build(await select(params), params.userArguments, context); +}); + +backend.build.discoverTests(async (params) => ({ tests: (await select(params)).flatMap((project) => + (project.tests ?? []).map(({ suite, case: testCase }) => ({ suite, case: testCase, + project: { name: project.name, path: project.path } }))), +})); + +backend.build.test(async (params, context) => { + const selected = (await select(params)).flatMap((project) => (project.tests ?? []) + .filter((test) => (params.suite === undefined || params.suite === test.suite) + && (params.case === undefined || params.case === test.case)) + .map((test) => ({ project, test }))); + if (selected.length === 0 && (params.suite !== undefined || params.case !== undefined)) { + throw new PluginError('sample.test', 'The selected test no longer exists.'); + } + let output = ''; + for (const { project, test } of selected) { + const result = await run(test.command, project, params.userArguments, context); + output += result.stdout + result.stderr; + if (result.exitCode !== 0) return { exitCode: result.exitCode, output }; + } + return { exitCode: 0, output }; +}); + +backend.build.prepareRun((params, context) => prepare(params, context, false)); +backend.build.prepareDebug((params, context) => prepare(params, context, true)); +async function prepare(params, context, debug) { + const selected = await select(params); + const project = selected[0]; + if (selected.length !== 1 || params.run !== 'application' || !project.run + || params.target?.name !== 'Main' || params.target?.path !== project.path) { + throw new PluginError('sample.target', 'The selected application no longer exists.'); + } + const result = await build(selected, [], context); + if (result.exitCode !== 0) throw new PluginError('sample.buildFailed', 'Build failed before launch.'); + if (debug) { + if (typeof project.run.debugProgram !== 'string') throw new PluginError('sample.target', 'This application has no native debug program.'); + return { program: posix.join(project.path, project.run.debugProgram), arguments: params.userArguments, + workingDirectory: project.path, environment: {} }; + } + return { executable: { kind: 'command', value: project.run.command }, + arguments: [...project.run.arguments, ...params.userArguments], + workingDirectory: project.path, environment: {} }; +} + +async function build(selected, arguments_, context) { + let output = ''; + for (const project of selected) { + context.progress({ message: `Building ${project.name}` }); + const result = await run(project.build, project, arguments_, context); + output += result.stdout + result.stderr; + if (result.exitCode !== 0) return { exitCode: result.exitCode, output }; + } + return { exitCode: 0, output }; +} + +async function run(command, project, arguments_, context) { + context.throwIfCancelled(); + if (!Array.isArray(command) || command.length === 0 || !command.every((part) => typeof part === 'string')) { + throw new PluginError('sample.command', 'A tool command must be a nonempty argument array.'); + } + return context.run(command[0] === 'node' ? process.execPath : command[0], [...command.slice(1), ...arguments_], { + cwd: await inside(project.path), + onStdout: (output) => context.progress({ output }), + onStderr: (output) => context.progress({ output }), + }); +} + +async function select(params) { + const all = await projects(params.root); + if (!params.project) return all; + const selected = all.filter(({ name, path }) => name === params.project.name && path === params.project.path); + if (selected.length !== 1) throw new PluginError('sample.project', 'The selected project no longer exists.'); + return selected; +} + +async function projects(root) { + const base = await inside(root); + const config = JSON.parse(await readFile(join(base, 'lithe-sample.json'), 'utf8')); + if (!Array.isArray(config.projects)) throw new PluginError('sample.config', 'A projects array is required.'); + return Promise.all(config.projects.map(async (project) => { + if (typeof project.path !== 'string' || isAbsolute(project.path)) throw new PluginError('sample.path', 'A project path must be relative to its instance.'); + const directory = await realpath(resolve(base, project.path)); + if (directory !== base && !directory.startsWith(base + sep)) throw new PluginError('sample.path', 'A project is outside its instance.'); + // The model is instance-relative; later request selectors are workspace-relative. + const modelPath = relative(base, directory).split(sep).join('/') || '.'; + return { ...project, modelPath, path: posix.join(root, modelPath) }; + })); +} + +async function inside(path) { + if (typeof path !== 'string' || isAbsolute(path)) throw new PluginError('sample.path', 'A workspace-relative directory is required.'); + const directory = await realpath(resolve(workspace, path)); + if (directory !== workspace && !directory.startsWith(workspace + sep)) { + throw new PluginError('sample.path', 'The directory is outside the workspace.'); + } + return directory; +} + +await backend.listen(); diff --git a/sdk/node/examples/sample-native-build/plugin.json b/sdk/node/examples/sample-native-build/plugin.json new file mode 100644 index 0000000..b475e22 --- /dev/null +++ b/sdk/node/examples/sample-native-build/plugin.json @@ -0,0 +1,156 @@ +{ + "schemaVersion": 2, + "id": "org.example.sample-native-build", + "displayName": "Sample Native Build", + "version": "0.1.0", + "pluginAPIVersion": 4, + "requires": { + "buildProviders": 5, + "buildSystems": 1, + "serverProcess": 1, + "debugAdapters": 2 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build", + "debug.launch" + ], + "activationEvents": [ + "onBuildSystem:sample", + "onDebug:native" + ], + "languages": [], + "buildSystems": [ + { + "id": "sample", + "displayName": "Sample", + "detection": { + "markers": [ + "lithe-sample.json" + ], + "nesting": "outermost" + }, + "invocation": { + "executables": [ + { + "kind": "command", + "command": "node" + } + ] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "node", + "minimumVersion": "20.3", + "entry": "backend/main.mjs" + }, + "arguments": [], + "executesProjectCode": true, + "mayUseNetwork": false + }, + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "group": "build", + "scopes": [ + "workspace", + "project" + ], + "source": "provider", + "arguments": [] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ] + }, + "sync": { + "watch": [ + "lithe-sample.json" + ] + }, + "test": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ], + "filters": [ + "suite", + "case" + ], + "arguments": [], + "discovery": true + }, + "run": [ + { + "id": "application", + "source": "provider", + "name": "Run Application", + "fromTargets": { + "tags": [ + "application" + ] + }, + "arguments": [], + "debug": { + "adapter": "native" + } + } + ] + } + ], + "debugAdapters": [ + { + "id": "native", + "displayName": "Native DAP", + "requests": [ + "launch" + ], + "selectors": [ + { + "targetKinds": [ + "executable" + ] + } + ], + "configurationTypes": [ + "native" + ], + "configurations": { + "launch": { + "targetBindings": { + "program": "target.path", + "cwd": "target.workingDirectory", + "args": "target.arguments", + "env": "target.environment" + } + } + }, + "launch": { + "process": { + "launch": { + "kind": "systemCommand", + "commands": [ + "example-dap" + ] + } + }, + "transport": "stdio" + } + } + ] +} diff --git a/sdk/node/package-lock.json b/sdk/node/package-lock.json new file mode 100644 index 0000000..543b506 --- /dev/null +++ b/sdk/node/package-lock.json @@ -0,0 +1,51 @@ +{ + "name": "@lithe/plugin-sdk", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@lithe/plugin-sdk", + "version": "0.1.0", + "license": "Apache-2.0", + "devDependencies": { + "@types/node": "20.19.9", + "typescript": "5.9.3" + }, + "engines": { + "node": ">=20.3" + } + }, + "node_modules/@types/node": { + "version": "20.19.9", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-20.19.9.tgz", + "integrity": "sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/sdk/node/package.json b/sdk/node/package.json index cdf782f..6e44f80 100644 --- a/sdk/node/package.json +++ b/sdk/node/package.json @@ -19,6 +19,11 @@ "README.md" ], "scripts": { - "test": "node --test test/*.test.mjs" + "test": "node --test test/*.test.mjs", + "test:types": "tsc --project test/types/tsconfig.json" + }, + "devDependencies": { + "@types/node": "20.19.9", + "typescript": "5.9.3" } } diff --git a/sdk/node/src/backend.mjs b/sdk/node/src/backend.mjs index 19d883e..dd20314 100644 --- a/sdk/node/src/backend.mjs +++ b/sdk/node/src/backend.mjs @@ -13,6 +13,7 @@ import { PluginSdkError, toResponseError } from './errors.mjs'; import { killAllProcesses, runProcess } from './process.mjs'; import { BUILD_VERSION, + BUILD_METHOD_VERSIONS, boundedText, ERROR_CODES, FUNCTIONS_VERSION, @@ -30,6 +31,7 @@ import { const BUILD_FORMATS = Object.freeze({ [METHODS.buildModel]: 'litheBuildModel', [METHODS.buildDependencies]: 'litheDependencies', + [METHODS.buildDiscoverTests]: 'litheTestDiscovery', }); /** VCS capabilities that describe the VCS rather than a request it answers. */ @@ -56,6 +58,7 @@ class Backend { #capabilities; #functions = new Map(); #buildHandlers = new Map(); + #buildVersion = null; /** The declared VCS provider: `{ id, handlers: Map, capabilities }`. */ #vcs = null; #requestHandlers = new Map(); @@ -96,6 +99,13 @@ class Backend { model: (handler) => this.#declareBuild(METHODS.buildModel, handler), /** Answers `lithe/build/dependencies` with a `litheDependencies` document. */ dependencies: (handler) => this.#declareBuild(METHODS.buildDependencies, handler), + /** Runs a bounded task in the provider; the host owns its operation. */ + execute: (handler) => this.#declareBuild(METHODS.buildExecute, handler), + test: (handler) => this.#declareBuild(METHODS.buildTest, handler), + discoverTests: (handler) => this.#declareBuild(METHODS.buildDiscoverTests, handler), + /** Prepares a plan; the application itself belongs to the host. */ + prepareRun: (handler) => this.#declareBuild(METHODS.buildPrepareRun, handler), + prepareDebug: (handler) => this.#declareBuild(METHODS.buildPrepareDebug, handler), }); } @@ -104,6 +114,9 @@ class Backend { return this.#negotiated; } + /** Selected build protocol version, or null when unavailable. */ + get buildVersion() { return this.#buildVersion; } + /** The host's `initialize` params, or null before it arrives. */ get initializeParams() { return this.#initializeParams; @@ -364,8 +377,8 @@ class Backend { return; } const build = this.#buildHandlers.get(method); - if (build && this.#negotiated.build) { - this.#run(id, null, (context) => build(params ?? {}, context), (result) => buildResult(method, result)); + if (build && this.#buildVersion >= BUILD_METHOD_VERSIONS[method]) { + this.#invokeBuild(id, method, params ?? {}, build); return; } const vcs = this.#negotiated.vcs ? this.#vcs.methods.get(method) : undefined; @@ -407,9 +420,13 @@ class Backend { this.#initializeParams = params; const offered = params.capabilities?.experimental ?? {}; const offers = (entry, version) => Array.isArray(entry?.versions) && entry.versions.includes(version); + this.#buildVersion = this.#buildHandlers.size > 0 + ? Array.from({ length: BUILD_VERSION }, (_, index) => BUILD_VERSION - index) + .find((version) => offers(offered.litheBuild, version)) ?? null + : null; this.#negotiated = Object.freeze({ functions: offers(offered.litheFunctions, FUNCTIONS_VERSION), - build: this.#buildHandlers.size > 0 && offers(offered.litheBuild, BUILD_VERSION), + build: this.#buildVersion !== null, vcs: this.#vcs !== null && offers(offered.litheVcs, VCS_VERSION), }); try { @@ -432,7 +449,7 @@ class Backend { this.log('The host did not offer litheFunctions version 1; functions stay unavailable.'); } const experimental = { ...(this.#capabilities.experimental ?? {}) }; - if (this.#negotiated.build) experimental.litheBuild = { version: BUILD_VERSION }; + if (this.#negotiated.build) experimental.litheBuild = { version: this.#buildVersion }; if (this.#negotiated.vcs) { experimental.litheVcs = { version: VCS_VERSION, vcs: this.#vcs.id, capabilities: this.#vcs.capabilities }; } else if (this.#vcs) { @@ -469,6 +486,33 @@ class Backend { this.#respond(id, null); } + #invokeBuild(id, method, params, handler) { + if (Object.hasOwn(params ?? {}, 'modelInputs') && this.#buildVersion < 6) { + this.#respond(id, undefined, { code: ERROR_CODES.invalidParams, message: 'Model inputs require build protocol 6.' }); + return; + } + const progressing = [METHODS.buildExecute, METHODS.buildTest, METHODS.buildPrepareRun, METHODS.buildPrepareDebug].includes(method); + if (!params || typeof params !== 'object' || Array.isArray(params) + || (progressing && typeof params.operationID !== 'string')) { + this.#respond(id, undefined, { code: ERROR_CODES.invalidParams, message: 'A build request needs object params and its wire operationID.' }); + return; + } + const operationID = params.operationID; + let reporting = true; + this.#run(id, null, (context) => handler(params, progressing ? { + ...context, + operationID, + progress: ({ message, percentage, output } = {}) => { + if (!reporting || context.signal.aborted) return; + const report = { operationID }; + if (message !== undefined) report.message = boundedText(message, LIMITS.maxBuildMessageBytes); + if (output !== undefined) report.output = boundedText(output, LIMITS.maxBuildOutputBytes); + if (Number.isFinite(percentage)) report.percentage = Math.min(100, Math.max(0, Math.round(percentage))); + this.#write({ jsonrpc: '2.0', method: METHODS.buildProgress, params: report }); + }, + } : context), (result) => buildResult(method, result), () => { reporting = false; }); + } + #invokeFunction(id, { functionID, operationID, params }) { const handler = this.#negotiated.functions ? this.#functions.get(functionID) : undefined; if (!handler) { @@ -708,16 +752,47 @@ function requestKey(id) { */ function buildResult(method, result) { const expected = BUILD_FORMATS[method]; - if (result === null || typeof result !== 'object' || Array.isArray(result)) { - throw new PluginSdkError('sdk.build.invalidResult', `${method} must return a ${expected} object.`); - } - if (result.format !== undefined && result.format !== expected) { - throw new PluginSdkError('sdk.build.invalidResult', `${method} returned format ${result.format}, not ${expected}.`); - } - if (result.version !== undefined && result.version !== 1) { - throw new PluginSdkError('sdk.build.invalidResult', `${method} returned version ${result.version}, not 1.`); - } - return { ...result, format: expected, version: 1 }; + if (!isObject(result)) { + throw new PluginSdkError('sdk.build.invalidResult', `${method} must return a reply object.`); + } + let document = result; + if (expected) { + if (result.format !== undefined && result.format !== expected) { + throw new PluginSdkError('sdk.build.invalidResult', `${method} returned format ${result.format}, not ${expected}.`); + } + if (result.version !== undefined && result.version !== 1) { + throw new PluginSdkError('sdk.build.invalidResult', `${method} returned version ${result.version}, not 1.`); + } + document = { ...result, format: expected, version: 1 }; + } + if ([METHODS.buildExecute, METHODS.buildTest].includes(method) + && (!Number.isInteger(result.exitCode) || result.exitCode < -2147483648 || result.exitCode > 2147483647 + || typeof result.output !== 'string' || Buffer.byteLength(result.output) > LIMITS.maxBuildResultBytes)) { + throw new PluginSdkError('sdk.build.invalidResult', `${method} needs an int32 exitCode and bounded output.`); + } + if (method === METHODS.buildPrepareRun + && (!isObject(result.executable) || !['toolchain', 'command'].includes(result.executable.kind) + || typeof result.executable.value !== 'string' || typeof result.workingDirectory !== 'string' + || !Array.isArray(result.arguments) || !result.arguments.every((value) => typeof value === 'string') + || !isObject(result.environment) || !Object.values(result.environment).every((value) => typeof value === 'string'))) { + throw new PluginSdkError('sdk.build.invalidResult', `${method} needs a portable launch plan.`); + } + if (method === METHODS.buildPrepareDebug + && (typeof result.program !== 'string' || typeof result.workingDirectory !== 'string' + || !Array.isArray(result.arguments) || !result.arguments.every((value) => typeof value === 'string') + || !isObject(result.environment) || !Object.values(result.environment).every((value) => typeof value === 'string') + || Object.keys(result).some((key) => !['program', 'arguments', 'workingDirectory', 'environment'].includes(key)))) { + throw new PluginSdkError('sdk.build.invalidResult', `${method} needs native debug target facts, without an adapter or DAP arguments.`); + } + // Workspace containment and domain facts remain Core/provider responsibilities. + if (Buffer.byteLength(JSON.stringify(document)) > LIMITS.maxBuildResultBytes) { + throw new PluginSdkError('sdk.build.invalidResult', `${method} exceeds the build-result size limit.`); + } + return document; +} + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); } /** diff --git a/sdk/node/src/index.d.ts b/sdk/node/src/index.d.ts index dd7b030..d512f6d 100644 --- a/sdk/node/src/index.d.ts +++ b/sdk/node/src/index.d.ts @@ -58,6 +58,8 @@ export type BuildHandler = (params: BuildRequestParams, context: RequestContext) /** Params of `lithe/build/model` and `lithe/build/dependencies`. */ export interface BuildRequestParams { + /** Read-only host snapshot; missing producers remain unavailable. Requires protocol 6. */ + modelInputs?: BuildModelInputs; buildSystemID: string; root: string; options: JsonObject; @@ -72,6 +74,91 @@ export interface BuildRequestParams { [key: string]: unknown; } +/** Public normalized facts. Tasks, run plans, settings and optionValues never cross this boundary. */ +export interface BuildSharedModel { + projects: { id: string; name: string; path: string; displayName?: string; parentID?: string; properties: Record }[]; + targets: { id: string; projectID: string; name: string; path: string; tags: string[]; properties: Record }[]; + contentRoots: { projectID: string; path: string; kind: string }[]; +} +export type BuildModelInputInstance = + | { root: string; state: 'ready'; model: BuildSharedModel; configurationFingerprint: string; options: JsonObject } + | { root: string; state: 'loading' | 'stale' | 'failed' }; +export interface BuildModelInputs { + version: 1; + /** Opaque workspace-activation revision; discard cached work after it changes. */ + revision: number; + inputs: { pluginID: string; buildSystemID: string; state: 'available' | 'unavailable'; instances: BuildModelInputInstance[] }[]; +} + +/** Current upstream model selection, preserved as data rather than shell text. */ +export interface BuildSelection { + name: string; + path: string; + tags?: string[]; + properties?: JsonObject; +} + +export interface BuildExecutionParams extends BuildRequestParams { + task: string; + project?: BuildSelection | null; + target?: BuildSelection | null; + userArguments: string[]; + operationID: string; +} + +export interface BuildTestParams extends BuildRequestParams { + project?: BuildSelection | null; + target?: BuildSelection | null; + suite?: string; + case?: string; + userArguments: string[]; + operationID: string; +} + +export interface BuildRunParams extends BuildRequestParams { + run: string; + project: BuildSelection; + target: BuildSelection; + userArguments: string[]; + operationID: string; +} + +export interface BuildProgressReport extends ProgressReport { + /** Cut to 64 KiB of UTF-8 per notification. Build messages are cut to 4096 bytes. */ + output?: string; +} + +export interface BuildContext extends RequestContext { + /** The Core-assigned wire identity, never a plugin-selected value. */ + operationID: string; + /** Dropped after cancellation or completion. */ + progress(report: BuildProgressReport): void; +} + +export interface BuildExecutionResult { exitCode: number; output: string; } +export interface BuildTestDiscovery { + format?: 'litheTestDiscovery'; + version?: 1; + tests: { suite: string; case: string; project?: BuildSelection; target?: BuildSelection }[]; +} +export interface BuildRunPreparation { + executable: { kind: 'toolchain' | 'command'; value: string }; + arguments: string[]; + workingDirectory: string; + environment: Record; +} +export type BuildExecutionHandler = (params: BuildExecutionParams, context: BuildContext) => BuildExecutionResult | Promise; +export type BuildTestHandler = (params: BuildTestParams, context: BuildContext) => BuildExecutionResult | Promise; +export type BuildDiscoveryHandler = (params: BuildRequestParams, context: RequestContext) => BuildTestDiscovery | Promise; +export interface BuildDebugPreparation { + program: string; + arguments: string[]; + workingDirectory: string; + environment: Record; +} +export type BuildDebugHandler = (params: BuildRunParams, context: BuildContext) => BuildDebugPreparation | Promise; +export type BuildRunHandler = (params: BuildRunParams, context: BuildContext) => BuildRunPreparation | Promise; + /** Params every VCS handler receives, on top of its operation's arguments. */ export interface VcsRequestParams { root: { path: string }; @@ -138,10 +225,17 @@ export interface ListenOptions { export interface Backend { readonly negotiated: Negotiated; readonly initializeParams: JsonObject | null; + /** Highest common version (1-4), or null before/without negotiation. */ + readonly buildVersion: number | null; readonly functionIDs: string[]; readonly build: { model(handler: BuildHandler): Backend; dependencies(handler: BuildHandler): Backend; + execute(handler: BuildExecutionHandler): Backend; + test(handler: BuildTestHandler): Backend; + discoverTests(handler: BuildDiscoveryHandler): Backend; + prepareRun(handler: BuildRunHandler): Backend; + prepareDebug(handler: BuildDebugHandler): Backend; }; vcs(id: string, handlers: VcsHandlers, traits?: VcsTraits): Backend; function(id: string, handler: FunctionHandler): Backend; @@ -199,6 +293,9 @@ export const LIMITS: Readonly<{ maxPluginErrorCodeBytes: number; maxMessageBytes: number; maxResultBytes: number; + maxBuildResultBytes: number; + maxBuildMessageBytes: number; + maxBuildOutputBytes: number; maxVcsIdBytes: number; maxExecutionErrorOutputBytes: number; }>; diff --git a/sdk/node/src/protocol.mjs b/sdk/node/src/protocol.mjs index 99d87a8..d0a43dc 100644 --- a/sdk/node/src/protocol.mjs +++ b/sdk/node/src/protocol.mjs @@ -6,7 +6,7 @@ /** Protocol versions this SDK can answer. */ export const FUNCTIONS_VERSION = 1; -export const BUILD_VERSION = 1; +export const BUILD_VERSION = 6; export const VCS_VERSION = 1; export const METHODS = Object.freeze({ @@ -21,9 +21,26 @@ export const METHODS = Object.freeze({ functionProgress: 'lithe/function/progress', buildModel: 'lithe/build/model', buildDependencies: 'lithe/build/dependencies', + buildExecute: 'lithe/build/execute', + buildTest: 'lithe/build/test', + buildDiscoverTests: 'lithe/build/discoverTests', + buildPrepareRun: 'lithe/build/prepareRun', + buildPrepareDebug: 'lithe/build/prepareDebug', + buildProgress: 'lithe/build/progress', vcsExecution: 'lithe/vcs/execution', }); +/** Minimum negotiated version of each typed build operation. */ +export const BUILD_METHOD_VERSIONS = Object.freeze({ + [METHODS.buildModel]: 1, + [METHODS.buildDependencies]: 1, + [METHODS.buildExecute]: 2, + [METHODS.buildTest]: 3, + [METHODS.buildDiscoverTests]: 3, + [METHODS.buildPrepareRun]: 4, + [METHODS.buildPrepareDebug]: 5, +}); + /** * VCS-provider operations: the handler name a plugin declares, the request * Core sends for it, and the capability declaring the handler negotiates. @@ -78,6 +95,9 @@ export const LIMITS = Object.freeze({ maxPluginErrorCodeBytes: 64, maxMessageBytes: 1024, maxResultBytes: 1 << 20, + maxBuildResultBytes: 32 * 1024 * 1024, + maxBuildMessageBytes: 4096, + maxBuildOutputBytes: 65536, maxVcsIdBytes: 32, maxExecutionErrorOutputBytes: 16 * 1024, }); diff --git a/sdk/node/test/build.test.mjs b/sdk/node/test/build.test.mjs new file mode 100644 index 0000000..080eea2 --- /dev/null +++ b/sdk/node/test/build.test.mjs @@ -0,0 +1,207 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { createBackend, LIMITS, PluginError } from '../src/index.mjs'; +import { gate, TestHost, withTimeout } from './harness.mjs'; + +const launch = { + executable: { kind: 'toolchain', value: 'project-jdk' }, + arguments: ['Main', 'a b'], workingDirectory: 'app', environment: {}, +}; +const methods = [ + ['execute', 2, { exitCode: 0, output: 'built\n' }], + ['test', 3, { exitCode: 2, output: 'test failed\n' }], + ['discoverTests', 3, { tests: [{ suite: 'Suite', case: 'exact # [case]' }] }], + ['prepareRun', 4, launch], + ['prepareDebug', 5, { program: 'app/bin/main', arguments: [], workingDirectory: 'app', environment: {} }], +]; + +for (const version of [1, 2, 3, 4, 5, 6]) { + test(`build protocol ${version} admits only its typed methods`, { timeout: 15000 }, async (t) => { + const backend = createBackend(); + const invoked = []; + for (const [method, , reply] of methods) backend.build[method](() => { invoked.push(method); return reply; }); + const host = new TestHost(backend); + t.after(() => host.close()); + const { result } = await host.initialize({ litheBuild: { versions: [version, 99] } }); + assert.equal(result.capabilities.experimental.litheBuild.version, version); + assert.equal(backend.buildVersion, version); + for (const [method, minimum, reply] of methods) { + const response = await host.request(`lithe/build/${method}`, { operationID: `wire-${method}` }); + if (version < minimum) { + assert.equal(response.error.code, -32601); + assert.ok(!invoked.includes(method)); + } else { + assert.deepEqual(response.result, method === 'discoverTests' + ? { ...reply, format: 'litheTestDiscovery', version: 1 } : reply); + } + } + }); +} + +test('build negotiation chooses the highest common integer version', { timeout: 15000 }, async (t) => { + for (const versions of [[1, 4, 3, 2], [99, '4', true], []]) { + const backend = createBackend(); + backend.build.execute(() => ({ exitCode: 0, output: '' })); + const host = new TestHost(backend); + t.after(() => host.close()); + const response = await host.initialize({ litheBuild: { versions } }); + assert.equal(backend.buildVersion, versions[0] === 1 ? 4 : null); + assert.deepEqual(response.result.capabilities.experimental, + versions[0] === 1 ? { litheBuild: { version: 4 } } : {}); + await host.close(); + } +}); + +test('build progress preserves wire identity, UTF-8 limits and call ownership', { timeout: 15000 }, async (t) => { + const release = gate(); + const entered = gate(); + let late; + const backend = createBackend(); + backend.build.prepareRun(async (params, context) => { + assert.equal(params.operationID, context.operationID); + params.operationID = 'plugin-cannot-replace-the-wire-id'; + assert.equal(context.operationID, 'wire-42'); + late = context.progress; + context.progress({ message: '😀'.repeat(2000), output: '界'.repeat(30000), percentage: 120 }); + entered.open(); + await release.opened; + return launch; + }); + const host = new TestHost(backend); + t.after(async () => { release.open(); await host.close(); }); + await host.initialize({ litheBuild: { versions: [4] } }); + const pending = host.request('lithe/build/prepareRun', { operationID: 'wire-42' }); + await withTimeout(entered.opened, 'preparation to begin'); + const report = (await host.waitFor((message) => message.method === 'lithe/build/progress')).params; + assert.equal(report.operationID, 'wire-42'); + assert.equal(Buffer.byteLength(report.message), 4096); + assert.equal(Buffer.byteLength(report.output), 65535); + assert.equal(report.percentage, 100); + release.open(); + assert.deepEqual((await pending).result, launch); + late({ message: 'late output', output: 'late' }); + assert.equal(host.messages.filter((message) => message.method === 'lithe/build/progress').length, 1); +}); + +test('native debug progress preserves wire identity, UTF-8 limits and call ownership', { timeout: 15000 }, async (t) => { + const release = gate(); + const entered = gate(); + let late; + const backend = createBackend(); + backend.build.prepareDebug(async (params, context) => { + assert.equal(params.operationID, context.operationID); + params.operationID = 'plugin-cannot-replace-the-wire-id'; + assert.equal(context.operationID, 'wire-42'); + late = context.progress; + context.progress({ message: '😀'.repeat(2000), output: '界'.repeat(30000), percentage: 120 }); + entered.open(); + await release.opened; + return methods.at(-1)[2]; + }); + const host = new TestHost(backend); + t.after(async () => { release.open(); await host.close(); }); + await host.initialize({ litheBuild: { versions: [5] } }); + const pending = host.request('lithe/build/prepareDebug', { operationID: 'wire-42' }); + await withTimeout(entered.opened, 'preparation to begin'); + const report = (await host.waitFor((message) => message.method === 'lithe/build/progress')).params; + assert.equal(report.operationID, 'wire-42'); + assert.equal(Buffer.byteLength(report.message), 4096); + assert.equal(Buffer.byteLength(report.output), 65535); + assert.equal(report.percentage, 100); + release.open(); + assert.deepEqual((await pending).result, methods.at(-1)[2]); + late({ message: 'late output', output: 'late' }); + assert.equal(host.messages.filter((message) => message.method === 'lithe/build/progress').length, 1); +}); + +test('cancelling one build drops its progress while a sibling call completes', { timeout: 15000 }, async (t) => { + const entered = gate(); + const siblingEntered = gate(); + const release = gate(); + const backend = createBackend(); + backend.build.execute(async (_params, context) => { + entered.open(); + await release.opened; + context.progress({ message: 'cancelled progress' }); + return { exitCode: 0, output: '' }; + }); + backend.build.test(async () => { + siblingEntered.open(); + await release.opened; + return { exitCode: 3, output: 'independent failure' }; + }); + const host = new TestHost(backend); + t.after(async () => { release.open(); await host.close(); }); + await host.initialize({ litheBuild: { versions: [4] } }); + const pending = host.request('lithe/build/execute', { operationID: 'wire-cancel' }, 42); + const sibling = host.request('lithe/build/test', { operationID: 'wire-other' }, 43); + await withTimeout(entered.opened, 'execution to begin'); + await withTimeout(siblingEntered.opened, 'sibling execution to begin'); + host.notify('$/cancelRequest', { id: 42 }); + release.open(); + assert.equal((await pending).error.code, -32800); + assert.deepEqual((await sibling).result, + { exitCode: 3, output: 'independent failure' }); + assert.ok(!host.messages.some((message) => message.method === 'lithe/build/progress')); +}); + +test('invalid build replies fail without terminating the session or falling back', { timeout: 15000 }, async (t) => { + let reply; + const backend = createBackend(); + backend.build.execute(() => reply); + backend.build.discoverTests(() => ({ tests: [], format: 'wrong' })); + backend.build.prepareRun(() => ({ ...launch, executable: false })); + const host = new TestHost(backend); + t.after(() => host.close()); + await host.initialize({ litheBuild: { versions: [4] } }); + for (reply of [{}, { exitCode: true, output: '' }, { exitCode: 2147483648, output: '' }, + { exitCode: 0, output: false }, { exitCode: 0, output: 'x'.repeat(LIMITS.maxBuildResultBytes + 1) }]) { + assert.equal((await host.request('lithe/build/execute', { operationID: 'wire-invalid' })).error.code, -32603); + } + for (const method of ['discoverTests', 'prepareRun']) { + assert.equal((await host.request(`lithe/build/${method}`, { operationID: 'wire-invalid' })).error.code, -32603); + } + reply = { exitCode: 7, output: 'tool failure is a result' }; + assert.deepEqual((await host.request('lithe/build/execute', { operationID: 'wire-valid' })).result, reply); +}); + +test('build parameters need a wire operation ID and backend errors keep their category', { timeout: 15000 }, async (t) => { + const backend = createBackend(); + backend.build.execute(() => { throw new PluginError('sample.failed', 'Build rejected'); }); + const host = new TestHost(backend); + t.after(() => host.close()); + await host.initialize({ litheBuild: { versions: [4] } }); + assert.equal((await host.request('lithe/build/execute', {})).error.code, -32602); + assert.equal((await host.request('lithe/build/execute', [])).error.code, -32602); + const failed = await host.request('lithe/build/execute', { operationID: 'wire-failed' }); + assert.deepEqual(failed.error, { code: -32000, message: 'Build rejected', data: { code: 'sample.failed' } }); +}); + +test('native debug refuses adapter, process and arbitrary DAP result fields', { timeout: 15000 }, async (t) => { + for (const field of ['adapter', 'launch', 'dapArguments']) { + const backend = createBackend(); + backend.build.prepareDebug(() => ({ ...methods.at(-1)[2], [field]: 'untrusted' })); + const host = new TestHost(backend); + t.after(() => host.close()); + await host.initialize({ litheBuild: { versions: [5] } }); + assert.ok((await host.request('lithe/build/prepareDebug', { operationID: 'wire' })).error); + } +}); + +test('read-only model inputs require protocol 6 and preserve host identity', { timeout: 15000 }, async (t) => { + const modelInputs = { version: 1, revision: 17, inputs: [ + { pluginID: 'dev.source', buildSystemID: 'source', state: 'unavailable', instances: [] }, + ] }; + for (const version of [5, 6]) { + const seen = []; + const backend = createBackend(); + backend.build.model((params) => { seen.push(params.modelInputs); return { projects: [], targets: [], tasks: [] }; }); + const host = new TestHost(backend); + t.after(() => host.close()); + await host.initialize({ litheBuild: { versions: [version] } }); + const response = await host.request('lithe/build/model', { modelInputs }); + if (version === 5) { assert.equal(response.error.code, -32602); assert.equal(seen.length, 0); } + else { assert.deepEqual(seen, [modelInputs]); assert.ok(response.result); } + await host.close(); + } +}); diff --git a/sdk/node/test/types/build.mts b/sdk/node/test/types/build.mts new file mode 100644 index 0000000..dfa0ffd --- /dev/null +++ b/sdk/node/test/types/build.mts @@ -0,0 +1,64 @@ +// Check the published package's exports, including contextual return types. +import { createBackend, type BuildContext, type BuildRequestParams } from '@lithe/plugin-sdk'; + +const backend = createBackend(); +const version: number | null = backend.buildVersion; +void version; +backend.build.execute((params, context) => { + const task: string = params.task; + const operation: string = context.operationID; + context.progress({ message: task, output: operation, percentage: 50 }); + return { exitCode: 0, output: params.userArguments.join(' ') }; +}); +backend.build.test(async (params, context) => { + const suite: string | undefined = params.suite; + context.progress({ output: suite }); + return { exitCode: 2, output: 'test failure' }; +}); +backend.build.discoverTests(async (params: BuildRequestParams) => ({ + tests: [{ suite: 'Suite', case: 'exact # [case]', project: { name: 'app', path: params.root } }], +})); +backend.build.prepareRun(async (params, context: BuildContext) => { + context.throwIfCancelled(); + return { executable: { kind: 'toolchain', value: 'project-jdk' }, + arguments: ['Main', ...params.userArguments], workingDirectory: params.project.path, environment: {} }; +}); +backend.build.model((_params, context) => { + // @ts-expect-error Model requests have no bounded execution progress. + context.progress({ message: 'invalid' }); + return { projects: [] }; +}); +// @ts-expect-error The exit code is a number, not a shell/string result. +backend.build.execute(() => ({ exitCode: '0', output: '' })); +// @ts-expect-error Every discovered selector needs an exact case. +backend.build.discoverTests(() => ({ tests: [{ suite: 'incomplete' }] })); +// @ts-expect-error Arbitrary executable kinds are not portable launch plans. +backend.build.prepareRun(() => ({ executable: { kind: 'absolute', value: '/fixture/tool' }, + arguments: [], workingDirectory: '.', environment: {} })); + +backend.build.prepareDebug((params, context) => { + context.progress({ output: params.run }); + return { program: 'app/bin/main', arguments: params.userArguments, + workingDirectory: params.project.path, environment: {} }; +}); +// @ts-expect-error A native debug target requires a program path. +backend.build.prepareDebug(() => ({ arguments: [], workingDirectory: '.', environment: {} })); + +backend.build.model((params) => { + for (const slot of params.modelInputs?.inputs ?? []) { + const producer: string = slot.pluginID; + void producer; + for (const instance of slot.instances) { + if (instance.state === 'ready') { + const projects: string[] = instance.model.projects.map((project) => project.id); + void projects; + // @ts-expect-error Public inputs never carry executable tasks. + void instance.model.tasks; + } else { + // @ts-expect-error Old facts are absent while inputs are stale/loading/failed. + void instance.model; + } + } + } + return { projects: [] }; +}); diff --git a/sdk/node/test/types/tsconfig.json b/sdk/node/test/types/tsconfig.json new file mode 100644 index 0000000..7cd6f42 --- /dev/null +++ b/sdk/node/test/types/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "types": ["node"] + }, + "files": ["build.mts"] +}