From d5886f247b7eb2a8485950d028788c024055d2bd Mon Sep 17 00:00:00 2001 From: Dario Valdespino Date: Tue, 27 Oct 2026 19:55:49 -0300 Subject: [PATCH 1/2] feat: improve build extension authoring and self-build editor support --- .bazelignore | 1 + CONTRIBUTING.md | 48 + README.md | 35 + packages/core/BUILD.bazel | 1 + packages/core/src/artifacts.ts | 10 +- packages/core/src/buildExtensions.ts | 145 +++ packages/core/src/index.ts | 1 + packages/core/test/artifacts.test.ts | 18 +- packages/core/test/buildExtensions.test.ts | 100 ++ packages/vscode/.vscodeignore | 2 + packages/vscode/BUILD.bazel | 51 +- packages/vscode/README.md | 31 + packages/vscode/media/typescript/LICENSE | 21 + packages/vscode/media/typescript/build.d.ts | 854 ++++++++++++++++++ packages/vscode/media/typescript/index.js | 133 +++ packages/vscode/media/typescript/package.json | 11 + packages/vscode/package-vsix.cjs | 37 + packages/vscode/package.json | 13 + .../snippets/build-extension-js.code-snippets | 14 + .../build-extension-pkl.code-snippets | 23 + .../snippets/build-extension.code-snippets | 26 + packages/vscode/src/buildExtensions.ts | 49 + packages/vscode/src/codelens.ts | 5 +- packages/vscode/src/explorer.ts | 15 +- packages/vscode/src/extension.ts | 2 + .../vscode/test/buildTypescriptPlugin.test.ts | 197 ++++ .../vscode/test/integration/buildExtension.ts | 159 ++++ packages/vscode/test/integration/index.ts | 15 +- packages/vscode/test/integration/runner.ts | 50 +- packages/vscode/test/integration/selfBuild.ts | 148 +++ samples/build-extension/BUILD.bazel | 14 + samples/build-extension/elide.pkl | 21 + samples/build-extension/tools/javascript.mjs | 7 + samples/build-extension/tools/tasks.ts | 5 + 34 files changed, 2227 insertions(+), 35 deletions(-) create mode 100644 packages/core/src/buildExtensions.ts create mode 100644 packages/core/test/buildExtensions.test.ts create mode 100644 packages/vscode/media/typescript/LICENSE create mode 100644 packages/vscode/media/typescript/build.d.ts create mode 100644 packages/vscode/media/typescript/index.js create mode 100644 packages/vscode/media/typescript/package.json create mode 100644 packages/vscode/package-vsix.cjs create mode 100644 packages/vscode/snippets/build-extension-js.code-snippets create mode 100644 packages/vscode/snippets/build-extension-pkl.code-snippets create mode 100644 packages/vscode/snippets/build-extension.code-snippets create mode 100644 packages/vscode/src/buildExtensions.ts create mode 100644 packages/vscode/test/buildTypescriptPlugin.test.ts create mode 100644 packages/vscode/test/integration/buildExtension.ts create mode 100644 packages/vscode/test/integration/selfBuild.ts create mode 100644 samples/build-extension/BUILD.bazel create mode 100644 samples/build-extension/elide.pkl create mode 100644 samples/build-extension/tools/javascript.mjs create mode 100644 samples/build-extension/tools/tasks.ts diff --git a/.bazelignore b/.bazelignore index 7b4c356..c887fad 100644 --- a/.bazelignore +++ b/.bazelignore @@ -3,3 +3,4 @@ packages/core/node_modules packages/vscode/node_modules samples/ktjvm/.dev samples/workspace/.dev +samples/build-extension/.dev diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bab8801..22cb1b7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,6 +91,38 @@ scenario instead, against `samples/workspace` or the directory `ELIDE_WORKSPACE_ ELIDE_TEST_SCENARIO=workspace bazel run //packages/vscode:integration_test ``` +`ELIDE_TEST_SCENARIO=build-extension` opens `samples/build-extension` with third-party extensions +disabled. It exercises the built-in TypeScript service before sync (inferred and configured TS +projects, JSDoc JS, task/tool completion, invalid-option diagnostics, snippets and non-Elide +isolation), manifest links, real task execution, archive Build lenses, and task-list refresh after +saved additions/removals. Select a build-extension-capable Elide distribution explicitly: + +```sh +ELIDE_TEST_SCENARIO=build-extension \ +ELIDE_TEST_HOME=/path/to/extension-capable-elide \ +bazel run //packages/vscode:integration_test +``` + +The TypeScript plugin sources and canonical API snapshot live in +`packages/vscode/media/typescript`; Bazel packages them under +`node_modules/elide-build-typescript-plugin` because tsserver only accepts plugin package names. +The snapshot's pinned upstream source and any local backports are recorded in its `package.json`; +update it with the canonical `packages/types/build.d.ts` from Elide, preserving its license and +retaining backports not yet present upstream. Language-service unit regressions use the real +pinned `ts.server.ProjectService` and the packaged plugin loader. + +To run the scenario against the shipped files instead of the development output tree, first build +`//packages/vscode:vsix`, then set `ELIDE_TEST_VSIX="$PWD/bazel-bin/packages/vscode/elide.vsix"` +on the same integration invocation. The runner extracts that archive into a temporary extension +directory, copies the test bundle beneath it so VS Code attributes both to the same extension, +and removes the directory afterward. Shipped assets remain unchanged. This local-only path requires +`unzip`. The JVM scenario preserves `ELIDE_TEST_HOME` across temporary runtime-override checks. + +`packages/vscode/package-vsix.cjs` retains the pinned vsce manifest/README/license processing and +secret checks, but explicitly includes the unbundled TypeScript plugin: vsce's no-dependencies +traversal skips root `node_modules` regardless of `.vscodeignore`. Keep the packaged smoke in the +verification loop when upgrading vsce or changing plugin packaging. + `ELIDE_TEST_SCENARIO=komodo` drives `samples/ktjvm` through Komodo's language server instead of the JetBrains one, with every installed extension disabled (as on a machine without the JetBrains extension), and exercises each capability the server advertises through VS Code's provider commands, logging `PASS`/`FAIL`/`SKIP` per check and a @@ -118,6 +150,22 @@ ELIDE_TEST_HOME="${ELIDE_TEST_HOME:?set to an Elide distribution with managed Ko bazel run //packages/vscode:integration_test ``` +`ELIDE_TEST_SCENARIO=self-build` opens Elide's self-build checkout in place, with third-party +extensions disabled. It checks synchronization (including processor-source generation), native +reference/implementation CodeLenses, full/range semantic highlighting with KDoc, diagnostics, +cross-module and library navigation, unsaved edit/revert recovery, task discovery, and build-extension +TypeScript authoring. Sync updates generated artifacts; editor source edits are unsaved, reverted, +and checked against disk. Use an Elide build that recognizes `kotlin.annotationProcessing` and a +Komodo image with the Java PSI message bundles, platform registry defaults, and KDoc resolver +reflection registrations: + +```sh +ELIDE_TEST_SCENARIO=self-build \ +ELIDE_SELF_BUILD_ROOT="$HOME/workspaces/elide" \ +ELIDE_TEST_HOME=/path/to/compatible-elide \ +bazel run //packages/vscode:integration_test +``` + `ELIDE_TEST_SCENARIO=compose-komodo` opens the Compose sample (as `compose` does, below) with the `komodo` preview engine and checks that every card renders live in the language server process, with no JVM child. diff --git a/README.md b/README.md index fd1b13c..2206188 100644 --- a/README.md +++ b/README.md @@ -554,6 +554,41 @@ supported; read what a pragma reports through its table-valued function, e.g. values fail explicitly rather than being silently truncated. Select smaller values or fewer columns when needed. +## Authoring build extensions + +JavaScript/TypeScript build extensions use VS Code's built-in TypeScript language service. In an +Elide project (an ancestor contains `elide.pkl`), the extension supplies the build API declarations: +`ElideBuild`, task contexts/options, state, inputs/outputs and `ctx.tools`. Completion, hover, +Go to Definition and type diagnostics work before the first sync, with or without `tsconfig.json`. +No dependency install, generated configuration or Kotlin language server is needed. Existing +project-provided global `ElideBuild` declarations take precedence over the bundled API. + +1. Create `tools/tasks.ts` and insert **Elide build extension** from **Insert Snippet** (prefix + `elide-build-extension`). Use `elide-build-task` to add another task. The JavaScript snippet + supplies the equivalent JSDoc annotation; enable `// @ts-check` for JavaScript type diagnostics. +2. Declare the module in `elide.pkl`: + ```pkl + extensions { + "tools/tasks.ts" + } + ``` + Local literal declarations, including `new { from = "tools/tasks.ts" config { ... } }`, link + directly to their files from the current editor buffer. Shared member paths such as + `../tools/tasks.ts` stay within the Elide workspace. Links never fetch remote extensions. + Computed paths, raw/multiline Pkl strings and files outside the source tree are not linked. +3. In **Elide → Tasks → Build targets**, run the task your extension declares. Save module/helper + edits to refresh the task list; this does not re-sync JVM dependencies. Builds use saved files. +4. Package reusable extensions with `Extensions.ExtensionArchive`. Its manifest **Build** lens + runs `package-extension-` and writes the `.elx` archive. With Pkl language support + installed, `elide-extension` inserts a declaration and `elide-extension-archive` inserts an + artifact (after `import "elide:Extensions.pkl" as Extensions`). + +The runnable example is [`samples/build-extension`](samples/build-extension). Build/sync require +an Elide distribution supporting build extensions, selected with `elide.home`; some older +development distributions share a version number without supporting them. Editor declarations +are a bundled API snapshot, not a CLI capability probe or a substitute for declarations in a +standalone `tsc` build. They do not install Node.js types or change the project's compiler settings. + ## Settings | Setting | Default | Description | diff --git a/packages/core/BUILD.bazel b/packages/core/BUILD.bazel index 1d36a37..f08c93a 100644 --- a/packages/core/BUILD.bazel +++ b/packages/core/BUILD.bazel @@ -18,6 +18,7 @@ ts_project( "src/androidTests.ts", "src/androidWireless.ts", "src/artifacts.ts", + "src/buildExtensions.ts", "src/buildOutput.ts", "src/buildTasks.ts", "src/commandTimeline.ts", diff --git a/packages/core/src/artifacts.ts b/packages/core/src/artifacts.ts index 4160600..65752fa 100644 --- a/packages/core/src/artifacts.ts +++ b/packages/core/src/artifacts.ts @@ -1,9 +1,10 @@ import path from "node:path"; +import { androidFeatureTask } from "./android.js"; import { braceDelta, stripComment } from "./pkl.js"; /** One entry of the `artifacts` block of `elide.pkl`. */ export interface ManifestArtifact { - /** Mapping key of the entry, which is the build target name `elide build` takes. */ + /** Mapping key of the entry; most artifact classes use it directly as their build target. */ name: string; /** Unqualified Pkl class the entry instantiates (`NativeImage`, `Jar`, `StaticSite`); empty when it is not a `new`. */ type: string; @@ -128,6 +129,13 @@ export function isAndroidFeature(artifact: ManifestArtifact): boolean { return artifact.type === "AndroidFeature"; } +/** Build target registered for a manifest artifact; some pipelines prefix the artifact's mapping key. */ +export function artifactBuildTarget(artifact: ManifestArtifact): string { + if (isAndroidFeature(artifact)) return androidFeatureTask(artifact.name); + if (artifact.type === "ExtensionArchive") return `package-extension-${artifact.name}`; + return artifact.name; +} + /** Whether the artifact is built by the Android pipeline at all, whichever archive it publishes. */ export function isAndroidArtifact(artifact: ManifestArtifact): boolean { return isAndroidApk(artifact) || isAndroidAab(artifact) || isAndroidAar(artifact) || isAndroidFeature(artifact); diff --git a/packages/core/src/buildExtensions.ts b/packages/core/src/buildExtensions.ts new file mode 100644 index 0000000..195a510 --- /dev/null +++ b/packages/core/src/buildExtensions.ts @@ -0,0 +1,145 @@ +import { pklBlock } from "./pkl.js"; + +/** A local literal in the top-level `extensions` listing; offsets exclude its quotes. */ +export interface ManifestBuildExtension { + path: string; + start: number; + end: number; +} + +interface Token { + text: string; + start: number; + end: number; + value?: string; +} + +/** + * Navigation, not Pkl evaluation: only ordinary single-line literals and `new { from = "…" }` entries. + * Raw and multiline strings are deliberately unsupported: decline the document rather than guess its nesting. + */ +export function parseManifestBuildExtensions(text: string): ManifestBuildExtension[] { + if (/#"|"""/.test(text)) return []; + const block = pklBlock(text, "extensions"); + if (!block) return []; + const tokens = tokenize(text, block.start, block.end); + if (!tokens) return []; + const found: ManifestBuildExtension[] = []; + for (const entry of statements(tokens)) { + if (entry.length === 1) addLiteral(entry[0], found); + else if (entry[0]?.text === "new" && entry[1]?.text === "{" && entry.at(-1)?.text === "}") { + // Split only at the object's own depth: config strings and nested `from` properties are never destinations. + const members = entry.slice(2, -1); + for (let i = 0; i < members.length; i++) { + const token = members[i]!; + if (token.text === "{") { + i = closingBrace(members, i); + } else if (token.text === "from" && (i === 0 || separator(members[i - 1]!))) { + const literal = members[i + 2]; + const next = members[i + 3]; + if (members[i + 1]?.text === "=" && (!next || (separator(next) && !continuesExpression(members, i + 3)) || (next.text === "config" && members[i + 4]?.text === "{"))) { + addLiteral(literal, found); + } + } + } + } + } + return found; +} + +function addLiteral(token: Token | undefined, found: ManifestBuildExtension[]): void { + const value = token?.value; + if (!token || !value || /^(?:url:|npm:|maven:|https?:\/\/)/.test(value) || /[\0\r\n]/.test(value)) return; + found.push({ path: value, start: token.start + 1, end: token.end - 1 }); +} + +function separator(token: Token): boolean { + return token.text === "\n" || token.text === ";" || token.text === ","; +} + +/** A newline is not enough to prove a literal complete when the following token continues an expression. */ +function continuesExpression(tokens: Token[], at: number): boolean { + while (tokens[at] && separator(tokens[at]!)) at++; + return !!tokens[at] && /^[+?.*/%<>=!&|^-]$/.test(tokens[at]!.text); +} + +function closingBrace(tokens: Token[], open: number): number { + let depth = 0; + for (let i = open; i < tokens.length; i++) { + if (tokens[i]!.text === "{") depth++; + else if (tokens[i]!.text === "}" && --depth === 0) return i; + } + return tokens.length; +} + +function statements(tokens: Token[]): Token[][] { + const entries: Token[][] = []; + let start = 0; + for (let i = 0; i <= tokens.length; i++) { + const open = tokens[i]?.text; + if (open === "{" || open === "(" || open === "[") { + const stack = [open === "{" ? "}" : open === "(" ? ")" : "]"]; + for (i++; i < tokens.length && stack.length; i++) { + const text = tokens[i]!.text; + if (text === "{" || text === "(" || text === "[") stack.push(text === "{" ? "}" : text === "(" ? ")" : "]"); + else if (text === stack.at(-1)) stack.pop(); + } + if (stack.length) return entries; + i--; + } + if (i === tokens.length || (tokens[i] && separator(tokens[i]!) && !continuesExpression(tokens, i))) { + if (i > start) entries.push(tokens.slice(start, i)); + start = i + 1; + } + } + return entries; +} + +function tokenize(text: string, from: number, to: number): Token[] | undefined { + const tokens: Token[] = []; + for (let i = from; i < to;) { + const start = i; + const c = text[i]!; + if (c === "/" && text[i + 1] === "/") { + const end = text.indexOf("\n", i + 2); + i = end < 0 ? to : end; + } else if (c === "/" && text[i + 1] === "*") { + const end = text.indexOf("*/", i + 2); + if (end < 0 || end >= to) return undefined; + // A newline in a comment still separates listing entries. + if (text.slice(i, end).includes("\n")) tokens.push({ text: "\n", start, end: end + 2 }); + i = end + 2; + } else if (c === '"') { + let value = ""; + let valid = true; + for (i++; i < to && text[i] !== '"'; i++) { + const char = text[i]!; + if (char === "\n" || char === "\r") return undefined; + if (char !== "\\") value += char; + else { + const escaped = text[++i]; + if (escaped === '"' || escaped === "\\") value += escaped; + else if (escaped === "n") value += "\n"; + else if (escaped === "r") value += "\r"; + else if (escaped === "t") value += "\t"; + else if (escaped === "u" && text[i + 1] === "{") { + const end = text.indexOf("}", i + 2); + const hex = text.slice(i + 2, end); + const point = /^[0-9a-fA-F]{1,6}$/.test(hex) ? Number.parseInt(hex, 16) : -1; + if (end < 0 || point < 0 || point > 0x10ffff || (point >= 0xd800 && point <= 0xdfff)) valid = false; + else { value += String.fromCodePoint(point); i = end; } + } else valid = false; // Includes Pkl interpolation: its result is not a literal path. + } + } + if (i >= to) return undefined; + i++; + tokens.push({ text: "string", start, end: i, value: valid ? value : undefined }); + } else if (c === "\n" || !/\s/.test(c)) { + if (/[A-Za-z_$]/.test(c)) { + for (i++; i < to && /[A-Za-z0-9_$]/.test(text[i]!); i++); + } else i++; + tokens.push({ text: text.slice(start, i), start, end: i }); + } else i++; + } + return tokens; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3c2278e..d3337fe 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -33,6 +33,7 @@ export * from "./androidTests.js"; export * from "./composeInputs.js"; export * from "./artifacts.js"; export * from "./buildTasks.js"; +export * from "./buildExtensions.js"; export * from "./coverage.js"; export * from "./invocation.js"; export * from "./init.js"; diff --git a/packages/core/test/artifacts.test.ts b/packages/core/test/artifacts.test.ts index 8ae0b3f..a963b89 100644 --- a/packages/core/test/artifacts.test.ts +++ b/packages/core/test/artifacts.test.ts @@ -1,6 +1,6 @@ import path from "node:path"; import { describe, expect, test } from "bun:test"; -import { isNativeImageBinary, nativeImageBinary, parseManifestArtifacts } from "../src/index.js"; +import { artifactBuildTarget, isNativeImageBinary, nativeImageBinary, parseManifestArtifacts } from "../src/index.js"; /** Shape of a real manifest: a jar the image is built from, a binary image, and a shared library. */ const MANIFEST = `amends "elide:project.pkl" @@ -73,6 +73,22 @@ describe("parseManifestArtifacts", () => { }); }); +describe("artifactBuildTarget", () => { + test("an extension archive packages by mapping key, not output name", () => { + const [artifact] = parseManifestArtifacts(`artifacts { + ["tasks"] = new Extensions.ExtensionArchive { name = "published-name"; entrypoint = "tools/tasks.ts" } +}`); + expect(artifactBuildTarget(artifact!)).toBe("package-extension-tasks"); + }); + + test("Android features retain their module target and ordinary artifacts retain their key", () => { + expect(artifactBuildTarget({ name: "feature", type: "AndroidFeature", line: 0 })).toBe("android-module-feature"); + for (const type of ["Jar", "NativeImage", "AndroidApk", "AndroidAab", "AndroidAar", ""]) { + expect(artifactBuildTarget({ name: "app", type, line: 0 })).toBe("app"); + } + }); +}); + describe("isNativeImageBinary", () => { test("a Native Image is runnable unless it is a library", () => { const [jar, bin, shared] = parseManifestArtifacts(MANIFEST); diff --git a/packages/core/test/buildExtensions.test.ts b/packages/core/test/buildExtensions.test.ts new file mode 100644 index 0000000..35ea7ac --- /dev/null +++ b/packages/core/test/buildExtensions.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from "bun:test"; +import { parseManifestBuildExtensions } from "../src/index.js"; + +function paths(text: string): string[] { + return parseManifestBuildExtensions(text).map((entry) => entry.path); +} + +describe("parseManifestBuildExtensions", () => { + test("reads local bare and configured entries with source offsets", () => { + const text = `amends "elide:project.pkl" +extensions { + "tools/tasks.ts" + new { + from = "../tools/shared.mts" + config { message = "not-a-module.ts"; nested { from = "also-not.ts" } } + } + new { from = "tools/inline.js" config { message = "ignored.js" } } + "one.ts"; "two.mjs", "three.tsx" +}`; + expect(paths(text)).toEqual(["tools/tasks.ts", "../tools/shared.mts", "tools/inline.js", "one.ts", "two.mjs", "three.tsx"]); + for (const entry of parseManifestBuildExtensions(text)) { + expect(text.slice(entry.start, entry.end)).toBe(entry.path); + } + }); + + test("only the top-level listing and the entry's direct from property count", () => { + const text = `other { extensions { "nested.ts" } } +// extensions { "comment.ts" } +/* extensions { "block-comment.ts" } */ +extensions { + new { config { from = "config.ts" } } + unrelated { from = "unrelated.ts" } + new { nested { from = "nested-from.ts" } } + ["mapping"] = "mapping.ts" + "real.ts" +} +other { "outside.ts" }`; + expect(paths(text)).toEqual(["real.ts"]); + expect(paths(`other { extensions { "nested-only.ts" } }`)).toEqual([]); + }); + + test("comments and braces in strings do not invent entries or shift nesting", () => { + expect(paths(`extensions /* opening { */ { + // "comment.ts" } + "tools/{tasks}.ts" // } + /* "ignored.ts" { */ new { /* from = "no.ts" */ from = "yes.ts" } + "after.ts" +}`)).toEqual(["tools/{tasks}.ts", "yes.ts", "after.ts"]); + }); + + test("remote declarations and nonliteral expressions have no destination", () => { + expect(paths(`extensions { + "url:https://example.test/tasks.elx" + "npm:@example/tasks@1.0.0" + new { from = "maven:dev.example:tasks:1.0" } + "https://example.test/not-local.ts" + "prefix/" + "tasks.ts" + read("tasks.ts") + if (enabled) "conditional.ts" + new { from = base + "tasks.ts" } + new { from = "tools/" + module } + "prefix/" + + "continued.ts" + read( + "argument.ts" + ) + new { + from = "prefix/" + + "continued-from.ts" + } + "local.elx" +}`)).toEqual(["local.elx"]); + }); + + test("decodes supported Pkl escapes while preserving the literal source range", () => { + const text = String.raw`extensions { + "tools/quote\"name.ts" + "tools/back\\slash.ts" + "tools/\u{61}.ts" + "tools/\(computed).ts" + "tools/\q.ts" +}`; + const entries = parseManifestBuildExtensions(text); + expect(entries.map((entry) => entry.path)).toEqual(['tools/quote"name.ts', "tools/back\\slash.ts", "tools/a.ts"]); + expect(text.slice(entries[2]!.start, entries[2]!.end)).toBe(String.raw`tools/\u{61}.ts`); + }); + + test("only reserved fetch schemes exclude otherwise local paths", () => { + expect(paths(String.raw`extensions { "C:\\tools\\tasks.ts"; "custom:tasks.ts" }`)) + .toEqual(["C:\\tools\\tasks.ts", "custom:tasks.ts"]); + }); + + test("declines unsupported raw, multiline, and unfinished declarations", () => { + expect(paths('extensions { #"tools/raw.ts"# }')).toEqual([]); + expect(paths('extensions { """tools/multiline.ts""" }')).toEqual([]); + expect(paths('extensions { "unfinished.ts"')).toEqual([]); + expect(paths('extensions { new { from = "unfinished.ts } }')).toEqual([]); + expect(paths('extensions { "ok.ts" /* unfinished }')).toEqual([]); + }); +}); diff --git a/packages/vscode/.vscodeignore b/packages/vscode/.vscodeignore index 9ecb414..69ea2f5 100644 --- a/packages/vscode/.vscodeignore +++ b/packages/vscode/.vscodeignore @@ -12,3 +12,5 @@ !media/database/inspector.dex !language-configuration/** !syntaxes/** +!node_modules/elide-build-typescript-plugin/** +!snippets/** diff --git a/packages/vscode/BUILD.bazel b/packages/vscode/BUILD.bazel index ef34926..35a8e1e 100644 --- a/packages/vscode/BUILD.bazel +++ b/packages/vscode/BUILD.bazel @@ -1,12 +1,14 @@ load("@aspect_rules_esbuild//esbuild:defs.bzl", "esbuild") -load("@aspect_rules_js//js:defs.bzl", "js_binary") +load("@aspect_rules_js//js:defs.bzl", "js_binary", "js_run_binary") load("@aspect_rules_ts//ts:defs.bzl", "ts_config", "ts_project") load("@bazel_lib//lib:copy_to_bin.bzl", "copy_to_bin") +load("@bazel_lib//lib:copy_to_directory.bzl", "copy_to_directory") load("@npm//:defs.bzl", "npm_link_all_packages") load("@npm//packages/vscode:@vscode/vsce/package_json.bzl", vsce_bin = "bin") load("//tools/bazel/bun:defs.bzl", "bun_test") # The integration suite is bundled by the hand-written targets below, outside the tsc project (tsconfig.json covers src). +# gazelle:exclude package-vsix.cjs # gazelle:exclude test npm_link_all_packages(name = "node_modules") @@ -28,6 +30,7 @@ ts_project( "src/androidTesting.ts", "src/androidWireless.ts", "src/androidWirelessHtml.ts", + "src/buildExtensions.ts", "src/codelens.ts", "src/composeAgent.ts", "src/composeAgentMcp.ts", @@ -87,6 +90,7 @@ ts_config( src = "tsconfig.json", visibility = [":__subpackages__"], deps = [ + ":media/typescript/package.json", ":package.json", "//:tsconfig_base", "//packages/core:tsconfig", @@ -127,11 +131,24 @@ copy_to_bin( "README.md", "icon.png", "package.json", - ] + glob([ - "language-configuration/**", - "media/**", - "syntaxes/**", - ]), + ] + glob( + [ + "language-configuration/**", + "media/**", + "snippets/**", + "syntaxes/**", + ], + exclude = ["media/typescript/**"], + ), +) + +# tsserver accepts package names only and resolves them under each extension's node_modules. +# Keep the maintained sources in media, but package a real plugin at its standard load path. +copy_to_directory( + name = "typescript_plugin", + srcs = glob(["media/typescript/**"]), + out = "node_modules/elide-build-typescript-plugin", + root_paths = ["packages/vscode/media/typescript"], ) # Building the extension type-checks it: `:vscode` emits nothing, and its tsc run is the `_typecheck` output. @@ -140,11 +157,19 @@ filegroup( srcs = [ ":assets", ":bundle", + ":typescript_plugin", ":vscode_typecheck", ], ) -vsce_bin.vsce( +# Package the one unbundled tsserver dependency explicitly, without npm/yarn discovery or runtime dependencies. +js_binary( + name = "package_vsix", + data = [":node_modules/@vscode/vsce"], + entry_point = "package-vsix.cjs", +) + +js_run_binary( name = "vsix", srcs = [ ":extension", @@ -153,13 +178,9 @@ vsce_bin.vsce( "//:node_modules/@secretlint/secretlint-rule-preset-recommend", ], outs = ["elide.vsix"], - args = [ - "package", - "--no-dependencies", - "--out", - "elide.vsix", - ], + args = ["elide.vsix"], chdir = package_name(), + tool = ":package_vsix", ) # The pinned vsce, for publishing a built archive: `bazel run //packages/vscode:vsce -- publish --packagePath `. @@ -181,6 +202,8 @@ bun_test( ":node_modules/@elide/ide-core", ":node_modules/qrcode", ":node_modules/yauzl", + ":typescript_plugin", + "//:node_modules/typescript", ], ) @@ -228,6 +251,7 @@ js_binary( ":extension", ":integration_suite", ":node_modules/@vscode/test-electron", + "//samples/build-extension", "//samples/ktjvm", "//samples/workspace", ], @@ -235,6 +259,7 @@ js_binary( # Output-tree copies are read-only; the runner copies the samples out of its runfiles and edits those copies. no_copy_to_bin = [ "//samples/ktjvm", + "//samples/build-extension", "//samples/workspace", ], # The runner resolves the extension's real path in the output tree (runner.ts), which the fs patch would keep inside diff --git a/packages/vscode/README.md b/packages/vscode/README.md index 30a6d13..35f0a98 100644 --- a/packages/vscode/README.md +++ b/packages/vscode/README.md @@ -125,6 +125,37 @@ accept); add it to `.gitignore` instead to cover every clone. | `Elide: Show Android Logcat` | Open a filtered device log stream; Filter, Clear, Stop and Restart commands manage it. | | `Elide: Explore Android Databases` | Browse and query a debuggable app's Room / SQLite databases on a connected device. | +## Build extension authoring + +The built-in TypeScript language service gets Elide's build API in projects beneath an `elide.pkl`, +before sync and without generating a `tsconfig.json` or installing dependencies. Annotate the default +export with `ElideBuild` for task/action/tool completion, hover, navigation and type diagnostics: + +```ts +export default ({ task }: ElideBuild) => { + task("greet", { description: "Greet from the build" }, (ctx) => { + ctx.log("Hello from", ctx.project.name); + }); +}; +``` + +Insert this using the `elide-build-extension` snippet; `elide-build-task` adds another task. +JavaScript has a JSDoc variant (`// @ts-check` enables its type diagnostics). Existing global +`ElideBuild` declarations in your project take precedence over the bundled snapshot. This is +editor support only: it does not configure standalone `tsc`, install Node.js types or change +compiler settings. + +Declare `"tools/tasks.ts"` in the manifest's `extensions` block and follow its link to the module; +configured `new { from = "..." config { ... } }` entries link too. Navigation reads unsaved manifest +text, stays within the source tree, and skips remote, computed, raw and multiline declarations. +Run tasks under **Elide → Tasks → Build targets**. Saving JS/TS modules or helpers refreshes that +list without re-syncing JVM dependencies; execution reads saved files. + +An `Extensions.ExtensionArchive` artifact's **Build** lens runs +`package-extension-` to produce its `.elx`. With Pkl language support installed, +`elide-extension` and `elide-extension-archive` snippets insert declarations and archive artifacts. +Build/sync need an Elide distribution supporting build extensions, selected with `elide.home`. + ## Settings | Setting | Default | Description | diff --git a/packages/vscode/media/typescript/LICENSE b/packages/vscode/media/typescript/LICENSE new file mode 100644 index 0000000..d8da95c --- /dev/null +++ b/packages/vscode/media/typescript/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Elide Technologies, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/vscode/media/typescript/build.d.ts b/packages/vscode/media/typescript/build.d.ts new file mode 100644 index 0000000..a0c31d5 --- /dev/null +++ b/packages/vscode/media/typescript/build.d.ts @@ -0,0 +1,854 @@ +/** + * Elide Build Extension APIs + * + * A build extension is a JavaScript or TypeScript module listed in a project's `elide.pkl` under + * `extensions`. Elide calls its default export while configuring the build; every `task(...)` the + * export declares becomes a real build task. + * + * @example + * + * ```ts + * export default ({ task, manifest }: ElideBuild) => { + * task("protoc", { description: "Generate protobuf sources", outputs: ["src/generated"] }, + * async (ctx) => { + * const { ok, stderr } = ctx.exec("protoc", ["--kotlin_out=src/generated", "api.proto"]); + * if (!ok) ctx.fail(stderr); + * }); + * }; + * ``` + */ + +/** Where the build reads and writes, as absolute paths. */ +interface ElideBuildLayout { + /** Project root: the directory holding `elide.pkl`. */ + readonly projectDir: string; + + /** Everything the build writes for this project (`.dev`). */ + readonly buildDir: string; + + /** Build state and the shared dependency repository; the workspace root's for a member. */ + readonly stateDir: string; + + /** Elide's bundled resources. */ + readonly resourcesDir: string; + + /** Built artifacts. */ + readonly artifacts: string; + + /** Staging area used while assembling artifacts. */ + readonly staging: string; + + /** Resolved dependencies, shared across a workspace. */ + readonly dependencies: string; +} + +/** The workspace a project belongs to. */ +interface ElideBuildWorkspace { + /** Workspace root project's name. */ + readonly name: string; + + /** Absolute path of the workspace root. */ + readonly root: string; + + /** This project's member name, or `null` when it is the workspace root. */ + readonly member: string | null; + + /** Every member's name, in declaration order. */ + readonly members: readonly string[]; +} + +/** Identity of the project being built. */ +interface ElideBuildProject { + /** Declared name, falling back to the project directory's name. */ + readonly name: string | null; + + /** Declared version, or `null` when the manifest declares none. */ + readonly version: string | null; + + /** Declared description, or `null` when the manifest declares none. */ + readonly description: string | null; + + /** Absolute path of the project root. */ + readonly root: string; + + /** The workspace this project belongs to, or `null` when it stands alone. */ + readonly workspace: ElideBuildWorkspace | null; +} + +/** + * Build options as parsed from the command line; `args` maps each option's name to every value + * passed for it in command-line order, and a bare flag maps to a single empty string. + */ +interface ElideBuildOptions { + readonly args: Readonly>; + + /** Tokens that appeared after the `--` separator, verbatim. */ + readonly flags: readonly string[]; +} + +/** + * The resolved project manifest, exactly as `elide project export --format=json` writes it: property + * names from `elide.pkl`, an `@type` discriminator on polymorphic entries, defaults absent. + */ +type ElideBuildManifest = Readonly>; + +/** The `config` block of this extension's entry in `extensions`, uninterpreted by Elide; empty when none. */ +type ElideBuildConfig = Readonly>; + +/** Result of running an external program. */ +interface ElideBuildExecResult { + /** The program's exit status. */ + readonly exitCode: number; + + /** Whether the program exited zero. */ + readonly ok: boolean; + + /** + * What the program wrote to standard output, kept up to 1 MiB; output past the cap is dropped here + * but still streamed. + */ + readonly stdout: string; + + /** What the program wrote to standard error, under the same cap as {@link stdout}. */ + readonly stderr: string; +} + +/** Options for {@link ElideBuildTaskContext.exec}. */ +interface ElideBuildExecOptions { + /** Working directory; relative paths resolve against the project root. Defaults to the root. */ + readonly cwd?: string; + + /** + * Environment entries added on top of the inherited environment; a non-string value is set as the + * guest displays it. An entry whose value is `undefined` or `null` is left as inherited rather than + * set. + */ + readonly env?: Readonly< + Record + >; + + /** Suppress forwarding the program's output to the build display. Defaults to `false`. */ + readonly quiet?: boolean; + + /** + * Milliseconds to wait before killing the program and failing the task; omitted, zero or negative + * waits as long as the program runs. Bounds the named program only: once it exits, output still + * arriving from anything it left running is read for one more second and then dropped. + */ + readonly timeout?: number; +} + +/** One diagnostic a built-in compiler reported. */ +interface ElideBuildToolDiagnostic { + readonly severity: "error" | "warning" | "info"; + + /** The compiler's message, without the rendered source excerpt. */ + readonly message: string; + + /** Path of the source file, as the compiler reported it; `null` when it named none. */ + readonly file: string | null; + + /** 1-based line, or `null` when the diagnostic has no position. */ + readonly line: number | null; + + /** 1-based column, or `null` when the diagnostic has no position. */ + readonly column: number | null; +} + +/** Result of running a built-in tool in-process. */ +interface ElideBuildToolResult { + /** Whether the tool succeeded. */ + readonly ok: boolean; + + /** The tool's exit status: `0` on success, its own code otherwise, `1` for a failure with none. */ + readonly exitCode: number; + + /** Which tool ran: `"kotlinc"`, `"javac"`, `"jar"`, `"native-image"` or a formatter's name. */ + readonly tool: string; + + /** + * What a compiler reported, in order and already shown in the build output. Empty for `jar` and + * `native-image`, whose output is text streamed to the build log. + */ + readonly diagnostics: readonly ElideBuildToolDiagnostic[]; +} + +/** Result of a {@link ElideBuildTools.format} run. */ +interface ElideBuildFormatResult extends ElideBuildToolResult { + /** + * Absolute paths of the files rewritten, or — under `check` — that would be rewritten. Empty + * when everything was already formatted. + */ + readonly changed: readonly string[]; +} + +/** Options for {@link ElideBuildTools.format}. */ +interface ElideBuildFormatOptions { + /** Which formatter to drive: ktfmt for Kotlin, google-java-format for Java. */ + readonly tool: "ktfmt" | "javaformat"; + + /** + * Files, directories or globs, project-relative; narrowed to what the formatter accepts (`.kt` + * and `.kts` for `"ktfmt"`, `.java` for `"javaformat"`). Matching nothing is a successful no-op. + */ + readonly files: readonly string[]; + + /** Report what would change and write nothing. Defaults to `false`, which rewrites in place. */ + readonly check?: boolean; + + /** Further flags for the formatter's own parser, e.g. `["--google-style"]`. */ + readonly args?: readonly string[]; +} + +/** Options for {@link ElideBuildTools.kotlinc}. */ +interface ElideBuildKotlincOptions { + /** Files, directories or globs, project-relative; only `.kt`, `.kts` and `.java` entries are passed. */ + readonly sources: readonly string[]; + + /** Output directory, or a path ending in `.jar` to package the classes directly. */ + readonly out: string; + + /** Compile classpath entries, project-relative or absolute. */ + readonly classpath?: readonly string[]; + + /** Output directories or JARs whose `internal` declarations this compilation may see. */ + readonly friendPaths?: readonly string[]; + + /** Further `kotlinc` flags, passed verbatim ahead of the classpath. */ + readonly args?: readonly string[]; + + /** Compile incrementally against the toolchain's cache for `out`. Defaults to `false`. */ + readonly incremental?: boolean; + + /** + * Mount the compiler plugins the manifest's `kotlin.features` and `kotlin.plugins` enable, the way + * `compile-kotlin-main` does; the runtimes those features provide join the classpath with them, + * since a plugin without its runtime fails the compiler rather than doing nothing. `false` runs + * the bare compiler: what annotation-only or plugin-free sources want. Defaults to `true`. + */ + readonly plugins?: boolean; +} + +/** Options for {@link ElideBuildTools.javac}. */ +interface ElideBuildJavacOptions { + /** Files, directories or globs, project-relative; only `.java` entries are passed. */ + readonly sources: readonly string[]; + + /** Output directory for class files. */ + readonly out: string; + + /** Compile classpath entries, project-relative or absolute. */ + readonly classpath?: readonly string[]; + + /** Annotation processor path entries; processing runs when present. */ + readonly processorPath?: readonly string[]; + + /** Further `javac` flags, passed verbatim after the classpath. */ + readonly args?: readonly string[]; +} + +/** One thing {@link ElideBuildTools.jar} packages. */ +interface ElideBuildJarEntry { + /** A directory, whose contents are packaged, or a single file; project-relative or absolute. */ + readonly from: string; + + /** Directory inside the archive the entry lands under; the root when omitted. */ + readonly into?: string; + + /** + * Read `from` as an archive and package the entries it holds, rather than adding the file + * itself. Never inferred from the extension, so a JAR named without it is added as one file. The + * source archive's own `META-INF/MANIFEST.MF` is dropped — the written archive carries the + * manifest `manifest` and `mainClass` describe — and `into` re-roots what is left. A member whose + * path would land outside the archive (`../x`, `/x`) fails the call. + */ + readonly archive?: boolean; + + /** Globs dropped from this entry alone, on top of {@link ElideBuildJarOptions.exclude}. */ + readonly exclude?: readonly string[]; +} + +/** Options for {@link ElideBuildTools.jar}. */ +interface ElideBuildJarOptions { + /** Where the archive is written; parent directories are created. */ + readonly out: string; + + /** + * What to package, in order. Two entries contributing the same archive path is not an error: the + * last one wins, and the build warns naming the path and the source it was taken from. Nothing + * is merged — a manifest `Jar` artifact unions `META-INF/services` files, this does not. + */ + readonly entries: readonly ElideBuildJarEntry[]; + + /** + * Archive-relative path globs dropped from every entry, whatever its source, matched against the + * path the entry would take inside the archive — after `into` re-roots it. Paths, not package + * names: `"kotlin/**"` drops a package tree, `"META-INF/*.SF"` a file kind. + */ + readonly exclude?: readonly string[]; + + /** + * Timestamp every entry is stamped with, ISO-8601 (`"2024-01-01T00:00:00Z"`). Makes a repack of + * unchanged input byte-identical; without it entry times are not reproducible — an entry may carry + * its file's modification time or the time of the call. + */ + readonly date?: string; + + /** Main-section manifest attributes, added to `Manifest-Version` and `Created-By`. */ + readonly manifest?: Readonly>; + + /** The `Main-Class` attribute; wins over the same key in `manifest`. */ + readonly mainClass?: string; + + /** Keep the tool's output out of the build log. Defaults to `false`. */ + readonly quiet?: boolean; +} + +/** Options for {@link ElideBuildTools.nativeImage}. */ +interface ElideBuildNativeImageOptions { + /** The `native-image` argument vector; relative paths in path-valued flags anchor on `cwd`. */ + readonly args: readonly string[]; + + /** Working directory; relative paths resolve against the project root. Defaults to the root. */ + readonly cwd?: string; + + /** Keep the builder's output out of the build log. Defaults to `false`. */ + readonly quiet?: boolean; +} + +/** Options for {@link ElideBuildTools.classpath}. */ +interface ElideBuildClasspathOptions { + /** Source set to answer for. Defaults to `"main"`, or `"test"` when `usage` is `"test"`. */ + readonly sourceSet?: string; + + /** + * Which classpath: `"compile"` (what the compile tasks see), `"runtime"`, `"test"` (the test + * set's runtime classpath), `"processor"` (annotation processors) or `"modules"`. Defaults to + * `"compile"`. + */ + readonly usage?: "compile" | "runtime" | "test" | "processor" | "modules"; +} + +/** + * The built-in toolchain, called in-process: no child process, no second manifest parse, and + * diagnostics reported through the build the way a built-in compile reports them. Every call + * blocks until the tool returns. Paths are project-relative unless absolute; an output written + * outside the project can be produced but never cached or restored. + */ +interface ElideBuildTools { + /** + * Version of this surface's option and result shapes. Moves on any incompatible change to them; + * an addition does not move it. Branch on it when an extension must run against more than one. + */ + readonly version: number; + + /** + * Compile Kotlin (and mixed Java) sources with the embedded compiler. Plugins the manifest + * declares or enables are applied as they are for `compile-kotlin-main`; no `-jvm-target` is + * added unless `args` carries one. Requires the embedded toolchain: a manifest selecting a custom + * or managed `kotlin.toolchain` is refused with a pointer to `exec`. + */ + kotlinc(options: ElideBuildKotlincOptions): ElideBuildToolResult; + + /** + * Compile Java sources with the embedded compiler. Requires the embedded toolchain: a manifest + * selecting the external `jvm.java.compiler.mode` is refused with a pointer to `exec`. + */ + javac(options: ElideBuildJavacOptions): ElideBuildToolResult; + + /** + * Package directories, files and the contents of other archives into a JAR with the embedded + * `jar` tool: one in-process call opens a source archive, drops entries by glob and writes a + * reproducible result. + */ + jar(options: ElideBuildJarOptions): ElideBuildToolResult; + + /** Run the embedded Native Image driver; the builder runs as a child JVM pinned to Elide's JDK. */ + nativeImage(options: ElideBuildNativeImageOptions): ElideBuildToolResult; + + /** + * Format Kotlin or Java sources with the embedded ktfmt or google-java-format. Rewrites in + * place unless `check`, which only reports; either way `changed` names the files that are not + * already formatted, and each one arrives as a diagnostic too — a warning under `check`, an + * info for a rewrite, an error for a file that could not be read or parsed. The exit status is + * non-zero when a file failed, or when `check` found one to reformat. + */ + format(options: ElideBuildFormatOptions): ElideBuildFormatResult; + + /** + * The resolved dependency classpath, as absolute paths in classpath order. Answers what has been + * resolved in this build, so a task reading it should `dependsOn: "maven-dependencies"`. + */ + classpath(options?: ElideBuildClasspathOptions): readonly string[]; +} + +/** How a task was declared, as the build sees it. */ +interface ElideBuildTaskInfo { + readonly name: string; + readonly description: string | null; + + /** Absolute path of the extension that declared this task. */ + readonly extension: string; + + /** Input patterns, the declared ones first and anything `prepare` added after them. */ + readonly inputs: readonly string[]; + + /** Output patterns, declared and added alike. */ + readonly outputs: readonly string[]; + + /** Whether the build cache may archive and restore this task's outputs. */ + readonly cache: boolean; + + /** The options this task declared, as the build validates and prints them. */ + readonly options: readonly ElideBuildTaskOption[]; +} + +/** One command-line option a task accepts, declared by {@link ElideBuildTaskOptions.options}. */ +interface ElideBuildTaskOption { + /** The flag's name, without leading dashes and in kebab-case: `"dry-run"` is `--dry-run`. */ + readonly name: string; + + /** Shown by `elide build --inspect` and `elide build --help`. */ + readonly description?: string; + + /** + * Whether the option takes a value: `"none"` is a bare flag and rejects `--name=x`, `"optional"` + * accepts both spellings, and `"required"` rejects the bare form. Defaults to `"none"`. + */ + readonly arity?: "none" | "optional" | "required"; + + /** Whether the option may be passed more than once; read with `ctx.values`. Defaults to `false`. */ + readonly repeatable?: boolean; + + /** Whether `--no-` is accepted, reading as `"false"`. Defaults to `false`. */ + readonly negatable?: boolean; + + /** + * What a read falls back to when the option was not passed; a number or boolean is read back as + * the string it was written as. + */ + readonly default?: string | number | boolean; + + /** + * Whether what this option was passed decides what the task produces, making it part of the + * task's up-to-date check and of its cache key: a task that ran under `--release` is then neither + * reported up to date nor restored from the cache for a run without it. Defaults to `true`. + * + * Turn it off for an option that changes whether or how loudly work happens rather than what it + * produces — `--verbose`, a force switch — which would otherwise make every build spelled + * differently miss the cache for no reason. An option the task reads without declaring is keyed + * on by nothing at all. + */ + readonly keyed?: boolean; +} + +/** What changed since the last run the build recorded for a task. */ +interface ElideBuildTaskChanges { + /** Declared input patterns whose files changed. */ + readonly inputs: readonly string[]; + + /** Declared output patterns that are missing or no longer what the last run left. */ + readonly outputs: readonly string[]; + + /** Names of values `prepare` declared that differ from the last run's. */ + readonly values: readonly string[]; + + /** Whether the extension file itself changed; it is an input like any other. */ + readonly extension: boolean; +} + +/** One file a task declared, as the build stamps it. */ +interface ElideBuildFile { + /** Project-relative path, `/`-separated whatever the platform writes. */ + readonly path: string; + + /** Absolute path, for handing to a program or to `node:fs`. */ + readonly absolute: string; + + /** Size in bytes. */ + readonly size: number; + + /** Last modification, in epoch milliseconds. */ + readonly modified: number; + + /** + * SHA-256 over the file's mode and contents, hex. Contents, not metadata: an edit that preserves + * a file's size and modification time changes this. + */ + readonly stamp: string; +} + +/** + * A task's declared inputs or outputs, resolved against the project and stamped — the same walk and + * the same digest the build's own up-to-date checking runs on them. + * + * Resolution happens per call: an action reading `ctx.outputs` after writing them sees what it + * produced, which is what the build is about to record. + */ +interface ElideBuildTaskFiles { + /** + * Every regular file the declarations name, sorted by path and without duplicates; a declaration + * naming nothing contributes none. Narrow with one or more project-relative paths or globs, which + * need not be ones this task declared. + */ + files( + ...patterns: readonly (string | readonly string[])[] + ): readonly ElideBuildFile[]; + + /** + * SHA-256 over the paths, modes and contents of those same files, hex. + * + * One pattern answers exactly what the build recorded for that declaration, so comparing it with + * a stamp kept in {@link ElideBuildTaskContext.state} tells a task what changed within a + * declaration that {@link ElideBuildTaskChanges} only names as a whole. + */ + stamp(...patterns: readonly (string | readonly string[])[]): string; +} + +/** + * A task's outputs, which its `prepare` hook and its action may both add to. + * + * An output added while the action runs is recorded with the rest when it returns, so a task that + * only learns what it wrote by writing it still has its tree stamped and archived. + */ +interface ElideBuildTaskOutputs extends ElideBuildTaskFiles { + /** Declare more project-relative paths or globs this task writes. */ + add(...patterns: readonly (string | readonly string[])[]): void; +} + +/** + * A task's inputs while `prepare` runs, which is the one phase that may add to them: the question + * they answer — whether the task runs at all — is settled before the action starts. + */ +interface ElideBuildTaskInputs extends ElideBuildTaskFiles { + /** + * Declare more project-relative paths or globs whose contents decide whether the task re-runs. + * Stamped exactly as a declared `inputs` entry is. + */ + add(...patterns: readonly (string | readonly string[])[]): void; + + /** + * Declare an input that is not a file: `value` decides whether the task re-runs, and keys its + * cache entry, the way a declared pattern's contents do. Use it for anything the build can + * compute but not stat — a resolved classpath, a tool version, what a task this one depends on + * published through `ctx.state.of`. + * + * Strings, numbers, booleans, `null`, arrays and plain objects are stored, as in + * {@link ElideBuildTaskState}. Declaring `undefined` or `null` declares nothing. A changed value + * is named by {@link ElideBuildTaskChanges.values}. + */ + value(name: string, value: unknown): void; +} + +/** + * What one task is up to, read by another task in the same build. + * + * Obtained from {@link ElideBuildTaskState.of}, which answers `null` when the named task published + * nothing at all; a view whose {@link get} answers `null` is a task that published nothing under + * that key. + */ +interface ElideBuildTaskStateView { + /** Value recorded under `key`, or `null` when there is none. */ + get(key: string): unknown; + + /** Whether anything is recorded under `key`; `null` is a recorded value. */ + has(key: string): boolean; + + /** Every key recorded, in insertion order. */ + keys(): readonly string[]; +} + +/** + * Values a task keeps between builds, stored as structure rather than text, and the way it reads + * what another task recorded in this build. + * + * Strings, numbers, booleans, `null`, arrays and plain objects are stored; functions, symbols and + * `undefined` are not, and a self-referential value is rejected. A failed run discards the task's + * state entirely. + */ +interface ElideBuildTaskState { + /** Value stored under `key` by an earlier build, or `null` when there is none. */ + get(key: string): unknown; + /** + * Record `value` under `key`. Storing `undefined` or `null` removes it. + * + * What a task keeps is also one of its outputs: it is handed to dependents on a build that + * skipped this task, and it is what an up-to-date check compares a task that declares no files + * against — from the build after the one that first published it. + */ + set(key: string, value: unknown): void; + + /** Drop the value stored under `key`. */ + delete(key: string): void; + + /** Every key this task has stored, in insertion order. */ + keys(): readonly string[]; + + /** + * What the task named `task` is up to, or `null` when it published nothing — it is not a task an + * extension declared, it was not in this build, it failed, or it has not run yet. Depend on the + * task you read, or the answer is a race. + * + * A task the build skipped as up to date, or restored from the cache, answers with what it kept + * the last time it ran: being up to date is a statement about its outputs, and what it published + * is one of them. Reading your own name answers what you hold right now. + */ + of(task: string): ElideBuildTaskStateView | null; +} + +/** + * What a task's `prepare` hook is called with: the build, plus the declarations it is there to + * complete. + * + * Narrower than what the action gets, because a hook runs on every build — including the ones + * where the task turns out to have nothing to do. It cannot run programs, reach the toolchain, + * take the terminal, or ask what is dirty: that is the question it is helping to answer. + */ +interface ElideBuildPrepareContext { + readonly project: ElideBuildProject; + readonly manifest: ElideBuildManifest; + readonly config: ElideBuildConfig; + readonly layout: ElideBuildLayout; + readonly options: ElideBuildOptions; + readonly task: ElideBuildTaskInfo; + + /** This task's inputs, which the hook may add files and values to. */ + readonly inputs: ElideBuildTaskInputs; + + /** This task's outputs, which the hook may add to. */ + readonly outputs: ElideBuildTaskOutputs; + + /** + * What this task kept the last time it ran, and what other tasks are up to — the usual source of + * a derived input. Read-only here: what a task keeps is what its action kept. + */ + readonly state: Omit; + + /** First value passed for the named build option, or `null`; as on the action's context. */ + option(name: string): string | null; + + /** The named build option as a boolean; as on the action's context. */ + flag(name: string): boolean; + + /** Every value passed for the named build option, in command-line order. */ + values(name: string): readonly string[]; + + /** Write a line to the build log, attributed to this task. */ + log(...message: unknown[]): void; + + /** Write a warning, attributed to this task. */ + warn(...message: unknown[]): void; + + /** Write an error, attributed to this task. Does not fail the task on its own. */ + error(...message: unknown[]): void; +} + +/** What a task's action is called with. */ +interface ElideBuildTaskContext { + readonly project: ElideBuildProject; + readonly manifest: ElideBuildManifest; + readonly config: ElideBuildConfig; + readonly layout: ElideBuildLayout; + readonly options: ElideBuildOptions; + readonly task: ElideBuildTaskInfo; + + /** The files this task's `inputs` name, and their stamps. */ + readonly inputs: ElideBuildTaskFiles; + + /** + * The files this task's `outputs` name, and their stamps. An action may `add` to them: an output + * it only knows once it has written it is recorded with the rest when the action returns. + */ + readonly outputs: ElideBuildTaskOutputs; + + /** + * What changed since the last run. A task that never ran, or declared nothing, reports everything + * as changed; a task with unchanged inputs and outputs is skipped without running. + */ + readonly dirty: ElideBuildTaskChanges; + + /** Values this task keeps between builds, and what other tasks recorded in this one. */ + readonly state: ElideBuildTaskState; + + /** + * First value passed for the named build option, or `null`. An option this task declared resolves + * through its declaration, so `--no-` reads as `"false"` and an absent option reads as its + * declared default; any other name is read from the command line as parsed. + */ + option(name: string): string | null; + + /** + * The named build option as a boolean: a bare flag or `=true` is `true`, `--no-` or + * `=false` is `false`, and an option that was not passed reads as its declared default, else + * `false`. + */ + flag(name: string): boolean; + + /** + * Every value passed for the named build option, in command-line order; empty when it was not + * passed and declares no default. This is how a `repeatable` option is read. + */ + values(name: string): readonly string[]; + + /** Write a line to the build log, attributed to this task; build messages go to standard error. */ + log(...message: unknown[]): void; + + /** Write a warning, attributed to this task. */ + warn(...message: unknown[]): void; + + /** Write an error, attributed to this task. Does not fail the task on its own. */ + error(...message: unknown[]): void; + + /** + * Fail this task, with [message] as the reason; the action keeps running and the failure is + * reported when it returns. + */ + fail(...message: unknown[]): void; + + /** + * Fail this task and make the build exit with `code`, with [message] as the reason; `0` succeeds + * instead, so `exit(exec(...).exitCode)` is the whole pass/fail decision. The code reaches the + * process only when this task is the one that stopped the build; a code outside `0`-`255` wraps + * the way a shell reports one, and never wraps to zero. + */ + exit(code: number, ...message: unknown[]): void; + + /** Skip this task, with an optional reason shown in place of a completion message. */ + skip(...reason: unknown[]): void; + + /** + * Run an external program and block until it exits; output streams through attributed to this task + * unless `quiet` is set. No shell is involved: `args` reach the program as given. A bare `command` + * is looked up on `PATH`; one containing a separator resolves against `cwd`. + */ + exec( + command: string, + args?: readonly string[], + options?: ElideBuildExecOptions, + ): ElideBuildExecResult; + + /** Run an external program with no arguments of its own. */ + exec(command: string, options?: ElideBuildExecOptions): ElideBuildExecResult; + + /** + * Run `body` with the terminal: the progress display stops drawing for exactly as long as it + * takes, and resumes however it ends. Reach for it only when something writes to the build's own + * descriptors — a child spawned with inherited stdio, a REPL, a debugger — which the display + * cannot linearize. Output written through `ctx.log`, `ctx.exec`, `ctx.tools` or your task's own + * `console` needs none of it. + * + * An `async` `body` keeps the terminal until its promise settles, so `await ctx.foreground(...)` + * answers what `body` answered and rethrows what it threw. + */ + foreground(body: () => T): T; + + /** The built-in toolchain, called in-process. */ + readonly tools: ElideBuildTools; +} + +/** Options accepted by {@link ElideBuild.task}. */ +interface ElideBuildTaskOptions { + /** Shown by `elide build --inspect`. */ + readonly description?: string; + + /** Task keys that must complete before this one runs. */ + readonly dependsOn?: readonly string[] | string; + + /** + * Tasks that must not start until this one has run; the inverse of `dependsOn`, and the only way to + * precede a task this extension did not declare. `deps`, `compile` and `test` name every task in + * that group; any other name is a task key, and an unregistered one fails the build. A task named + * here pulls this one into the build whenever it runs, regardless of `default`. + */ + readonly before?: readonly string[] | string; + + /** + * Group this task joins: `deps`, `compile` and `test` are the groups `elide build ` + * resolves; any other name mints a group, letting several tasks share one target name. + */ + readonly group?: string; + + /** Whether a bare `elide build` runs this task. Defaults to `true`. */ + readonly default?: boolean; + + /** + * Project-relative paths or globs whose contents decide whether the task re-runs; up-to-date + * checking needs inputs — declared, or added by `prepare` — *and* something to compare them + * against: `outputs`, or the state the task publishes through {@link ElideBuildTaskState.set}. + * The extension file is always an input. + * + * "Contents" is literal: each declaration is stamped by hashing the mode and bytes of every file + * it names, and the action reads the same files and the same stamps through `ctx.inputs`. + */ + readonly inputs?: readonly string[]; + + /** Project-relative paths or globs the task writes; stamped as `inputs` are, through `ctx.outputs`. */ + readonly outputs?: readonly string[]; + + /** + * Run before this task's up-to-date check, to declare what only the running build can name: a + * generated source tree, a classpath another task resolved, a value that decides whether the + * work is still current. What it adds is stamped, keyed and reported exactly as a declared + * `inputs`/`outputs` entry is. + * + * Runs on every build the task is part of, including the ones where the action never does, so + * keep it to deriving declarations. Returning `false` fails the task before it runs; throwing + * reports the error. May be `async`. + */ + readonly prepare?: ElideBuildTaskPrepare; + + /** + * Whether the build cache may archive this task's outputs and restore them on a later build, in a + * different checkout included. Defaults to `true`; applies only to a task that is up-to-date + * checked at all. Turn it off for a task whose outputs depend on anything its inputs do not name. + */ + readonly cache?: boolean; + + /** + * Command-line options this task accepts, shown by `elide build --inspect` and `elide build + * --help` and checked before any task runs: a flag nobody declares fails the build. Read + * them with `ctx.option`, `ctx.flag` and `ctx.values`. + */ + readonly options?: readonly ElideBuildTaskOption[]; +} + +/** + * A task's action; may be `async`, in which case the build waits for it. Returning `false` fails the + * task; any other value, including none, succeeds. + */ +type ElideBuildTaskAction = ( + ctx: ElideBuildTaskContext, +) => void | boolean | Promise; + +/** + * A task's `prepare` hook; may be `async`, in which case the build waits for it. Returning `false` + * fails the task; any other value, including none, proceeds to the up-to-date check. + */ +type ElideBuildTaskPrepare = ( + ctx: ElideBuildPrepareContext, +) => void | boolean | Promise; + +/** What a build extension's default export is called with. */ +interface ElideBuild { + readonly project: ElideBuildProject; + readonly manifest: ElideBuildManifest; + readonly config: ElideBuildConfig; + readonly layout: ElideBuildLayout; + readonly options: ElideBuildOptions; + + /** First value passed for the named build option, or `null`. */ + option(name: string): string | null; + + /** Declare a build task. */ + task(name: string, action: ElideBuildTaskAction): void; + + /** Declare a build task with options. */ + task( + name: string, + options: ElideBuildTaskOptions, + action: ElideBuildTaskAction, + ): void; +} + +/** A build extension module's default export; runs during configuration, before any task, and may be `async`. */ +type ElideBuildExtension = (build: ElideBuild) => void | Promise; diff --git a/packages/vscode/media/typescript/index.js b/packages/vscode/media/typescript/index.js new file mode 100644 index 0000000..61b0e31 --- /dev/null +++ b/packages/vscode/media/typescript/index.js @@ -0,0 +1,133 @@ +"use strict"; + +const path = require("node:path"); + +// build.d.ts is vendored verbatim from elide-dev/elide, packages/types/build.d.ts +// (@elide-dev/types, MIT). Its notice is included in LICENSE beside this file. +const buildTypes = path.join(__dirname, "build.d.ts"); + +module.exports = function init({ typescript: ts }) { + const projects = new WeakMap(); + + function declaresBuild(source) { + const declaresName = (statement) => + (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement) || + ts.isClassDeclaration(statement) || ts.isEnumDeclaration(statement)) && + statement.name?.text === "ElideBuild"; + if (!ts.isExternalModule(source) && source.statements.some(declaresName)) return true; + return source.statements.some((statement) => + ts.isModuleDeclaration(statement) && + (statement.flags & ts.NodeFlags.GlobalAugmentation) !== 0 && + statement.body && ts.isModuleBlock(statement.body) && + statement.body.statements.some(declaresName)); + } + + function filesFor(state, roots) { + const { project, serverHost } = state.info; + const program = project.getCurrentProgram(); // Never getProgram(): that updates the graph recursively. + const version = project.getProjectVersion(); + if (state.version === version && state.program === program) return state.files; + state.version = version; + state.program = program; + + // Only inspect ancestors of project locations, not the filesystem tree. No extra watchers. + // Recheck on the next graph update, rather than caching absent manifests forever. + const directories = new Map(); + function inElide(directory) { + directory = path.resolve(directory); + if (directories.has(directory)) return directories.get(directory); + const parent = path.dirname(directory); + const found = serverHost.fileExists(path.join(directory, "elide.pkl")) || + (parent !== directory && inElide(parent)); + directories.set(directory, found); + return found; + } + const currentDirectory = project.getCurrentDirectory(); + const resolve = (file) => path.resolve(currentDirectory, file); + const isOurs = (file) => state.canonical(resolve(file)) === state.canonical(buildTypes); + const projectName = project.getProjectName(); + const scoped = inElide(currentDirectory) || + (path.isAbsolute(projectName) && !projectName.startsWith("/dev/null/") && + inElide(path.dirname(projectName))) || + roots.some((file) => !isOurs(file) && /\.[cm]?[jt]sx?$/i.test(file) && + inElide(path.dirname(resolve(file)))); + + let provided = false; + if (scoped) { + const rootNames = new Set(roots.map((file) => state.canonical(resolve(file)))); + const liveRoots = new Set(); + for (const file of roots) { + if (isOurs(file)) continue; + liveRoots.add(file); + const scriptVersion = project.getScriptVersion(file); + let cached = state.declarations.get(file); + if (!cached || cached.version !== scriptVersion) { + const snapshot = project.getScriptSnapshot(file); + const text = snapshot?.getText(0, snapshot.getLength()); + cached = { + version: scriptVersion, + provided: !!text?.includes("ElideBuild") && declaresBuild( + ts.createSourceFile(file, text, ts.ScriptTarget.Latest, false)), + }; + state.declarations.set(file, cached); + } + if (cached.provided) { provided = true; break; } + } + for (const file of state.declarations.keys()) { + if (!liveRoots.has(file)) state.declarations.delete(file); + } + // Includes ambient declarations supplied by imported packages and triple-slash references. + if (!provided && program) { + provided = program.getSourceFiles().some((source) => + !isOurs(source.fileName) && !rootNames.has(state.canonical(resolve(source.fileName))) && + !program.isSourceFileDefaultLibrary(source) && declaresBuild(source)); + } + } + const status = !scoped ? "outside an Elide project" : provided ? "using project-provided ElideBuild" : "providing build.d.ts"; + if (status !== state.status) { + project.projectService.logger.info(`[elide-build] ${status}: ${projectName}`); + state.status = status; + } + state.files = scoped && !provided ? [buildTypes] : []; + return state.files; + } + + return { + create(info) { + if (projects.has(info.project)) return info.languageService; + const host = info.languageServiceHost; + const originalFiles = host.getScriptFileNames.bind(host); + const caseSensitive = info.project.useCaseSensitiveFileNames(); + const state = { + info, + originalFiles, + canonical: caseSensitive ? (file) => file : (file) => file.toLowerCase(), + declarations: new Map(), + files: [], + }; + projects.set(info.project, state); + // getExternalFiles alone only attaches ScriptInfos for inferred projects; it does not + // add them to their program roots. Use the native host for both project kinds. + host.getScriptFileNames = () => { + const roots = originalFiles(); + const files = filesFor(state, roots); + const withoutOurs = roots.filter((file) => + state.canonical(path.resolve(info.project.getCurrentDirectory(), file)) !== state.canonical(buildTypes)); + return files.length || withoutOurs.length !== roots.length ? withoutOurs.concat(files) : roots; + }; + return info.languageService; + }, + getExternalFiles(project) { + const state = projects.get(project); + if (!state) return []; + const files = filesFor(state, state.originalFiles()); + const program = project.getCurrentProgram(); + // Imported ambient declarations become visible only after the first program is built. + // Invalidate its roots if that discovery changes which declaration supplies the API. + if (program && !!program.getSourceFile(buildTypes) !== (files.length !== 0)) { + project.markAsDirty(); + } + return files; + }, + }; +}; diff --git a/packages/vscode/media/typescript/package.json b/packages/vscode/media/typescript/package.json new file mode 100644 index 0000000..f80edf9 --- /dev/null +++ b/packages/vscode/media/typescript/package.json @@ -0,0 +1,11 @@ +{ + "name": "elide-build-typescript-plugin", + "private": true, + "version": "1.0.0", + "main": "index.js", + "type": "commonjs", + "license": "MIT", + "description": "Native TypeScript server support for Elide build extensions.", + "elideBuildTypesSource": "https://github.com/elide-dev/elide/blob/e3dee520e930012f541644a0cb90ce6f6a4eba95/packages/types/build.d.ts", + "elideBuildTypesPatches": ["Task actions and prepare hooks accept Promise, matching async callbacks with explicit Promise return types."] +} diff --git a/packages/vscode/package-vsix.cjs b/packages/vscode/package-vsix.cjs new file mode 100644 index 0000000..989c904 --- /dev/null +++ b/packages/vscode/package-vsix.cjs @@ -0,0 +1,37 @@ +"use strict"; + +const { createWriteStream } = require("node:fs"); +const path = require("node:path"); +const { createRequire } = require("node:module"); +const { pipeline } = require("node:stream/promises"); +// vsce's pinned collector performs manifest/README/license processing and secret validation. +// Its --no-dependencies traversal always skips root node_modules, even when .vscodeignore includes it. +const vsce = require("@vscode/vsce/out/package"); +const { ZipFile } = createRequire(require.resolve("@vscode/vsce"))("yazl"); + +async function main() { + const cwd = process.cwd(); + const options = { cwd, dependencies: false, packagePath: process.argv[2] }; + const manifest = await vsce.readManifest(cwd); + const files = await vsce.collect(manifest, options); + const plugin = "node_modules/elide-build-typescript-plugin"; + for (const name of ["package.json", "index.js", "build.d.ts", "LICENSE"]) { + const relative = `${plugin}/${name}`; + files.push({ path: `extension/${relative}`, localPath: path.join(cwd, relative) }); + } + // Include the declaration file's content type along with the regular extension assets. + files.find((file) => file.path === "[Content_Types].xml").contents = Buffer.from(await vsce.toContentTypes(files)); + await vsce.printAndValidatePackagedFiles(files, cwd, manifest, options); + const archive = new ZipFile(); + const written = pipeline(archive.outputStream, createWriteStream(options.packagePath)); + const mtime = new Date("2000-01-01T00:00:00Z"); + for (const file of files.sort((a, b) => a.path.localeCompare(b.path))) { + const zipOptions = { mtime, mode: file.mode }; + if (file.contents !== undefined) archive.addBuffer(Buffer.from(file.contents), file.path, zipOptions); + else archive.addFile(file.localPath, file.path, zipOptions); + } + archive.end(); + await written; +} + +main().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/packages/vscode/package.json b/packages/vscode/package.json index f59d5ca..ad09701 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -69,6 +69,19 @@ "onWebviewPanel:elide.composePreview" ], "contributes": { + "typescriptServerPlugins": [ + { + "name": "elide-build-typescript-plugin", + "enableForWorkspaceTypeScriptVersions": true + } + ], + "snippets": [ + { "language": "typescript", "path": "./snippets/build-extension.code-snippets" }, + { "language": "typescriptreact", "path": "./snippets/build-extension.code-snippets" }, + { "language": "javascript", "path": "./snippets/build-extension-js.code-snippets" }, + { "language": "javascriptreact", "path": "./snippets/build-extension-js.code-snippets" }, + { "path": "./snippets/build-extension-pkl.code-snippets" } + ], "mcpServerDefinitionProviders": [ { "id": "elide.composePreview", diff --git a/packages/vscode/snippets/build-extension-js.code-snippets b/packages/vscode/snippets/build-extension-js.code-snippets new file mode 100644 index 0000000..9ed1533 --- /dev/null +++ b/packages/vscode/snippets/build-extension-js.code-snippets @@ -0,0 +1,14 @@ +{ + "Elide build extension": { + "prefix": "elide-build-extension", + "description": "Declare an Elide build extension with JSDoc API types", + "body": [ + "/** @param {ElideBuild} build */", + "export default function ({ task }) {", + " task(\"${1:greet}\", { description: \"${2:Greet from the build}\" }, (ctx) => {", + " ${0:ctx.log(\"Hello from\", ctx.project.name);}", + " });", + "}" + ] + } +} diff --git a/packages/vscode/snippets/build-extension-pkl.code-snippets b/packages/vscode/snippets/build-extension-pkl.code-snippets new file mode 100644 index 0000000..b56aeed --- /dev/null +++ b/packages/vscode/snippets/build-extension-pkl.code-snippets @@ -0,0 +1,23 @@ +{ + "Elide build extension declaration": { + "scope": "pkl", + "prefix": "elide-extension", + "description": "Declare a local Elide build extension", + "body": [ + "extensions {", + " \"${1:tools/tasks.ts}\"", + "}" + ] + }, + "Elide extension archive artifact": { + "scope": "pkl", + "prefix": "elide-extension-archive", + "description": "Package a build extension (requires import \"elide:Extensions.pkl\" as Extensions)", + "body": [ + "[\"${1:tasks}\"] = new Extensions.ExtensionArchive {", + " entrypoint = \"${2:tools/tasks.ts}\"", + " srcs { \"${3:tools/**/*.ts}\" }", + "}" + ] + } +} diff --git a/packages/vscode/snippets/build-extension.code-snippets b/packages/vscode/snippets/build-extension.code-snippets new file mode 100644 index 0000000..264e659 --- /dev/null +++ b/packages/vscode/snippets/build-extension.code-snippets @@ -0,0 +1,26 @@ +{ + "Elide build extension": { + "prefix": "elide-build-extension", + "description": "Declare an Elide build extension with a typed task action", + "body": [ + "export default ({ task }: ElideBuild) => {", + " task(\"${1:greet}\", {", + " description: \"${2:Greet from the build}\",", + " }, (ctx) => {", + " ${0:ctx.log(\"Hello from\", ctx.project.name);}", + " });", + "};" + ] + }, + "Elide build task": { + "prefix": "elide-build-task", + "description": "Declare a task inside an Elide build extension", + "body": [ + "task(\"${1:greet}\", {", + " description: \"${2:Greet from the build}\",", + "}, (ctx) => {", + " ${0:ctx.log(\"Hello from\", ctx.project.name);}", + "});" + ] + } +} diff --git a/packages/vscode/src/buildExtensions.ts b/packages/vscode/src/buildExtensions.ts new file mode 100644 index 0000000..40f2318 --- /dev/null +++ b/packages/vscode/src/buildExtensions.ts @@ -0,0 +1,49 @@ +import { realpath, stat } from "node:fs/promises"; +import path from "node:path"; +import { MANIFEST_NAME, isPathUnder, normalizePath, parseManifestBuildExtensions } from "@elide/ide-core"; +import * as vscode from "vscode"; +import type { ElideWorkspace } from "./projects.js"; + +/** Local build-extension navigation reads the current editor buffer, never the last synced manifest model. */ +export function registerBuildExtensionLinks(workspace: ElideWorkspace): vscode.Disposable { + return vscode.languages.registerDocumentLinkProvider( + { scheme: "file", pattern: `**/${MANIFEST_NAME}` }, + { + async provideDocumentLinks(document, token) { + if (!vscode.workspace.isTrusted || document.uri.scheme !== "file" || path.basename(document.uri.fsPath) !== MANIFEST_NAME) return []; + const located = workspace.locate(document.uri); + if (!located) return []; + const project = workspace.projectAt(path.dirname(located.fsPath)); + if (!project) return []; + const version = document.version; + const links: vscode.DocumentLink[] = []; + let tree: string; + try { + tree = normalizePath(await realpath(project.workspaceRoot ?? project.root)); + } catch { + return []; + } + for (const declaration of parseManifestBuildExtensions(document.getText())) { + if (token.isCancellationRequested || document.version !== version || !vscode.workspace.isTrusted) return []; + const target = vscode.Uri.file(path.resolve(project.root, declaration.path)); + // A member may refer to ../tools within its workspace, but neither lexical traversal nor symlinks + // may turn a declaration into a link outside the source tree or the editor's workspace folders. + if (!workspace.locate(target)) continue; + try { + const resolved = normalizePath(await realpath(target.fsPath)); + if (!isPathUnder(resolved, tree) || !(await stat(target.fsPath)).isFile()) continue; + } catch { + continue; + } + if (token.isCancellationRequested || document.version !== version || !vscode.workspace.isTrusted) return []; + const link = new vscode.DocumentLink( + new vscode.Range(document.positionAt(declaration.start), document.positionAt(declaration.end)), target, + ); + link.tooltip = `Open build extension ${declaration.path}`; + links.push(link); + } + return links; + }, + }, + ); +} diff --git a/packages/vscode/src/codelens.ts b/packages/vscode/src/codelens.ts index 190d174..fbe4d33 100644 --- a/packages/vscode/src/codelens.ts +++ b/packages/vscode/src/codelens.ts @@ -2,8 +2,7 @@ import path from "node:path"; import { MANIFEST_NAME, isAndroidApk, - isAndroidFeature, - androidFeatureTask, + artifactBuildTarget, isNativeImageBinary, isPathUnder, normalizePath, @@ -103,7 +102,7 @@ function artifactLenses(line: number, length: number, root: string, artifact: Ma const build = new vscode.CodeLens(range, { title: "$(package) Build", command: "elide.build", - arguments: [{ root, args: [isAndroidFeature(artifact) ? androidFeatureTask(artifact.name) : artifact.name] }], + arguments: [{ root, args: [artifactBuildTarget(artifact)] }], }); if (isAndroidApk(artifact)) { // An APK is installed and launched by a build target of its own, and debugged on the device it was installed on. diff --git a/packages/vscode/src/explorer.ts b/packages/vscode/src/explorer.ts index dbe0a26..dfab36e 100644 --- a/packages/vscode/src/explorer.ts +++ b/packages/vscode/src/explorer.ts @@ -141,6 +141,7 @@ export class ElideProjectsView implements vscode.TreeDataProvider, El private readonly subscriptions: vscode.Disposable[]; /** `elide build --inspect` tasks per workspace root (a standalone project's own), dropped when the model changes. */ private readonly buildTasks = new Map(); + private buildGeneration = 0; /** Artifacts declared by each project's manifest, per project root, dropped when the model changes. */ private readonly artifacts = new Map(); @@ -151,6 +152,7 @@ export class ElideProjectsView implements vscode.TreeDataProvider, El this.subscriptions = [ workspace.onDidChange(() => { this.buildTasks.clear(); + this.buildGeneration++; this.artifacts.clear(); this.changed.fire(undefined); }), @@ -158,6 +160,16 @@ export class ElideProjectsView implements vscode.TreeDataProvider, El vscode.workspace.onDidChangeConfiguration((event) => { if (event.affectsConfiguration("elide.debug.adapter")) this.changed.fire(undefined); }), + // Extension tasks can change without a manifest edit. Helpers imported by an extension count too; + // invalidate lazily rather than re-syncing JVM dependencies on every script save. + vscode.workspace.onDidSaveTextDocument((document) => { + if (document.uri.scheme !== "file" || !/\.(?:[cm]?js|jsx|[cm]?ts|tsx)$/.test(document.uri.fsPath)) return; + const project = workspace.projectFor(document.uri.fsPath); + if (!project) return; + this.buildTasks.delete(project.workspaceRoot ?? project.root); + this.buildGeneration++; + this.changed.fire(undefined); + }), vscode.commands.registerCommand("elide.androidRefreshDevices", () => this.changed.fire(undefined)), vscode.tasks.onDidEndTask((event) => { if (event.execution.task.source === "elide") this.changed.fire(undefined); @@ -425,9 +437,10 @@ export class ElideProjectsView implements vscode.TreeDataProvider, El const cached = this.buildTasks.get(root); if (cached) return cached; const settings = readConfig(project.folder); + const generation = this.buildGeneration; const dist = resolveElideDistribution({ explicitHome: settings.home }); const tasks = await new ElideCli(dist, root, settings.flags).buildInspect(); - this.buildTasks.set(root, tasks); + if (generation === this.buildGeneration) this.buildTasks.set(root, tasks); return tasks; } diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index c05cbe5..70046de 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -25,6 +25,7 @@ import { registerAndroidLogcat } from "./androidLogcat.js"; import { registerAndroidReadiness } from "./androidReadiness.js"; import { registerAndroidWireless } from "./androidWireless.js"; import { ElideCodeLensProvider } from "./codelens.js"; +import { registerBuildExtensionLinks } from "./buildExtensions.js"; import { ComposeAgentPreviews } from "./composeAgent.js"; import { registerComposePreviewMcp, type ElideComposePreviewMcpApi } from "./composeAgentMcp.js"; import { registerComposePreview, type ElideComposePreviewApi } from "./composePreview.js"; @@ -80,6 +81,7 @@ export async function activate(context: vscode.ExtensionContext): Promise syncCommand(workspace, target)), vscode.commands.registerCommand("elide.installKomodo", () => installKomodoCommand(workspace, ui)), diff --git a/packages/vscode/test/buildTypescriptPlugin.test.ts b/packages/vscode/test/buildTypescriptPlugin.test.ts new file mode 100644 index 0000000..35018d7 --- /dev/null +++ b/packages/vscode/test/buildTypescriptPlugin.test.ts @@ -0,0 +1,197 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const extensionRoot = fileURLToPath(new URL("../", import.meta.url)); +const buildTypes = join(extensionRoot, "node_modules/elide-build-typescript-plugin/build.d.ts"); +const cleanup: Array<() => void> = []; +afterEach(() => { while (cleanup.length) cleanup.pop()!(); }); + +function projectFixture(files: Record, configured = false) { + const root = mkdtempSync(join(tmpdir(), "elide-build-types-")); + cleanup.push(() => rmSync(root, { recursive: true, force: true })); + const workspace = join(root, "workspace"); + const projectRoot = join(workspace, "project"); + mkdirSync(projectRoot, { recursive: true }); + if (configured) { + files = { + "tsconfig.json": JSON.stringify({ + compilerOptions: { strict: true, allowJs: true, checkJs: true, target: "ES2022", types: [] }, + include: ["**/*"], + }), + ...files, + }; + } + for (const [name, text] of Object.entries(files)) { + const file = join(projectRoot, name); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, text); + } + const logger: ts.server.Logger = { + close() {}, hasLevel: () => false, loggingEnabled: () => true, + perftrc() {}, info() {}, msg() {}, + startGroup() {}, endGroup() {}, getLogFileName: () => undefined, + }; + const host: ts.server.ServerHost = { + ...ts.sys, + getCurrentDirectory: () => workspace, + writeFile() { throw new Error("Language service must not write project files"); }, + watchFile: () => ({ close() {} }), + watchDirectory: () => ({ close() {} }), + setTimeout: () => 0, clearTimeout() {}, + setImmediate: () => 0, clearImmediate() {}, + }; + const service = new ts.server.ProjectService({ + host, logger, + cancellationToken: ts.server.nullCancellationToken, + useSingleInferredProject: false, + useInferredProjectPerProjectRoot: true, + globalPlugins: ["elide-build-typescript-plugin"], + pluginProbeLocations: [extensionRoot], + session: undefined, + }); + service.setCompilerOptionsForInferredProjects({ + strict: true, allowJs: true, checkJs: true, target: "ES2022", types: [], + }); + cleanup.push(() => { + for (const project of service.configuredProjects.values()) project.close(); + for (const project of service.inferredProjects) if (!project.isClosed()) project.close(); + }); + function open(name: string) { + const file = join(projectRoot, name); + service.openClientFile(file, undefined, undefined, workspace); + const project = service.getDefaultProjectForFile(ts.server.toNormalizedPath(file), true)!; + const language = project.getLanguageService(); + return { file, project, language }; + } + return { open, service, root: projectRoot }; +} + +const body = `{ + build.task("generate", { description: "Generate sources", cache: "invalid" }, (ctx) => { + ctx.tools.javac({ sources: ["src/Main.java"], out: "out" }); + ctx.exec("echo", { quiet: false }); + }); +};`; +const typed = `export default (build: ElideBuild) => ${body}`; +const javascript = `// @ts-check\n/** @param {ElideBuild} build */\nexport default (build) => ${body}`; + +function completions(language: ts.LanguageService, file: string, text: string, prefix: string) { + const position = text.indexOf(prefix) + prefix.length; + expect(position).toBeGreaterThan(prefix.length - 1); + return language.getCompletionsAtPosition(file, position, {})?.entries.map((entry) => entry.name) ?? []; +} + +test.each(["ts", "mts", "tsx", "js", "mjs", "jsx"])( + "inferred tools/tasks.%s uses the contributed loader and native completions, diagnostics and hover", + (extension) => { + const text = /^(js|mjs|jsx)$/.test(extension) ? javascript : typed; + const name = `tools/tasks.${extension}`; + const fixture = projectFixture({ "elide.pkl": "", [name]: text }); + const { file, project, language } = fixture.open(name); + expect(project.projectKind).toBe(ts.server.ProjectKind.Inferred); + expect(project.getExternalFiles()).toContain(buildTypes); + expect(completions(language, file, text, "build.")).toContain("task"); + expect(completions(language, file, text, "ctx.")).toEqual(expect.arrayContaining(["tools", "exec", "inputs", "dirty"])); + expect(completions(language, file, text, "ctx.tools.")).toEqual(expect.arrayContaining(["javac", "kotlinc", "format"])); + const diagnostics = language.getSemanticDiagnostics(file); + expect(diagnostics.some((diagnostic) => diagnostic.code === 2304)).toBe(false); + expect(diagnostics.some((diagnostic) => + ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n").includes("boolean"))).toBe(true); + const hover = language.getQuickInfoAtPosition(file, text.indexOf("javac") + 1)!; + expect(ts.displayPartsToString(hover.displayParts)).toContain("ElideBuildJavacOptions"); + expect(ts.displayPartsToString(hover.documentation)).toContain("Compile Java sources"); + }, +); + +test("configured projects supply the API without changing their tsconfig or requiring extension synchronization", () => { + const fixture = projectFixture({ "elide.pkl": "", "tools/tasks.ts": typed }, true); + const { file, project, language } = fixture.open("tools/tasks.ts"); + expect(project.projectKind).toBe(ts.server.ProjectKind.Configured); + expect(project.getExternalFiles()).toContain(buildTypes); + expect(completions(language, file, typed, "ctx.tools.")).toContain("javac"); + expect(language.getProgram()!.getSourceFile(buildTypes)).toBeDefined(); +}); + +test("async task actions and prepare hooks accept explicitly declared void promises", () => { + const text = `declare function stamp(ctx: ElideBuildTaskContext): Promise; +declare function prepare(ctx: ElideBuildPrepareContext): Promise; +declare function decide(ctx: ElideBuildTaskContext): Promise; +export default (build: ElideBuild) => { + build.task("stamp", { prepare }, stamp); + build.task("decide", decide); +};`; + const fixture = projectFixture({ "elide.pkl": "", "tools/tasks.ts": text }, true); + const { file, language } = fixture.open("tools/tasks.ts"); + expect(language.getSemanticDiagnostics(file)).toEqual([]); +}); + +test("ordinary TypeScript projects keep ElideBuild unresolved", () => { + const fixture = projectFixture({ "tools/tasks.ts": typed }, true); + const { file, project, language } = fixture.open("tools/tasks.ts"); + expect(project.getExternalFiles()).not.toContain(buildTypes); + expect(language.getProgram()!.getSourceFile(buildTypes)).toBeUndefined(); + expect(language.getSemanticDiagnostics(file).some((diagnostic) => diagnostic.code === 2304)).toBe(true); +}); + +test.each([ + "type ElideBuild = { custom: string }; type ElideBuildConfig = string;", + "export {}; declare global { interface ElideBuild { custom: string } }", +])("project-provided global declarations take precedence: %s", (declaration) => { + const text = "export default (build: ElideBuild) => build.custom;"; + const fixture = projectFixture({ "elide.pkl": "", "tools/tasks.ts": text, "build.d.ts": declaration }, true); + const { file, project, language } = fixture.open("tools/tasks.ts"); + expect(project.getExternalFiles()).not.toContain(buildTypes); + expect(language.getProgram()!.getSourceFile(buildTypes)).toBeUndefined(); + expect(language.getSemanticDiagnostics(file)).toHaveLength(0); + expect(completions(language, file, text, "build.")).toContain("custom"); +}); + +test("module-local declarations and text mentions do not shadow the canonical ambient API", () => { + const fixture = projectFixture({ + "elide.pkl": "", + "tools/tasks.ts": typed, + "local.ts": "export interface ElideBuild { local: true }; // interface ElideBuild {}", + "comment.d.ts": "// type ElideBuild = never;\ndeclare const label: 'ElideBuild';", + }, true); + const { file, project, language } = fixture.open("tools/tasks.ts"); + expect(project.getExternalFiles()).toContain(buildTypes); + expect(completions(language, file, typed, "build.")).toContain("task"); +}); + +test("ambient declarations reached through imports take precedence over vendored globals", () => { + const text = 'import "../ambient";\nexport default (build: ElideBuild) => build.custom;'; + const fixture = projectFixture({ + "elide.pkl": "", + "tools/tasks.ts": text, + "ambient.d.ts": "export {}; declare global { type ElideBuild = { custom: string } }", + "tsconfig.json": JSON.stringify({ compilerOptions: { types: [], strict: true }, files: ["tools/tasks.ts"] }), + }, true); + const { file, project, language } = fixture.open("tools/tasks.ts"); + // A dependency is discovered while constructing the first program. getExternalFiles + // invalidates the native graph when that changes which declaration supplies the API. + project.getExternalFiles(); + project.getLanguageService(); + expect(project.getExternalFiles()).not.toContain(buildTypes); + expect(language.getSemanticDiagnostics(file)).toHaveLength(0); +}); + +test("adding and removing a project declaration in an unsaved buffer refreshes the ambient API", () => { + const fixture = projectFixture({ "elide.pkl": "", "tools/tasks.ts": typed, "own.d.ts": "" }, true); + const { project, language } = fixture.open("tools/tasks.ts"); + fixture.open("own.d.ts"); + expect(language.getProgram()!.getSourceFile(buildTypes)).toBeDefined(); + const info = fixture.service.getScriptInfo(join(fixture.root, "own.d.ts"))!; + const own = "type ElideBuild = { custom: string };"; + info.editContent(0, 0, own); + project.updateGraph(); + expect(project.getExternalFiles()).not.toContain(buildTypes); + expect(language.getProgram()!.getSourceFile(buildTypes)).toBeUndefined(); + info.editContent(0, own.length, ""); + project.updateGraph(); + expect(project.getExternalFiles()).toContain(buildTypes); + expect(language.getProgram()!.getSourceFile(buildTypes)).toBeDefined(); +}); diff --git a/packages/vscode/test/integration/buildExtension.ts b/packages/vscode/test/integration/buildExtension.ts new file mode 100644 index 0000000..4e806c5 --- /dev/null +++ b/packages/vscode/test/integration/buildExtension.ts @@ -0,0 +1,159 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import * as vscode from "vscode"; +import type { ElideExtensionApi } from "../../src/extension.js"; +import { errorsOf, hoverAt, log, waitFor } from "./helpers.js"; + +/** Real built-in TS server, manifest providers, Elide sync and terminal builds; no companion Kotlin extension. */ +export async function runBuildExtensionScenario(): Promise { + const sample = process.env.ELIDE_TEST_SAMPLE!; + const extension = vscode.extensions.getExtension("elide.elide"); + assert.ok(extension); + const api = await extension.activate(); + const typescript = vscode.extensions.getExtension("vscode.typescript-language-features"); + assert.ok(typescript, "built-in TypeScript extension available"); + await typescript.activate(); + const taskUri = vscode.Uri.file(path.join(sample, "tools/tasks.ts")); + const taskDoc = await vscode.workspace.openTextDocument(taskUri); + await vscode.window.showTextDocument(taskDoc); + + // Inferred projects work before any Elide sync or project config has been written. + const hover = await waitFor("ElideBuild API hover", async () => { + const value = await hoverAt(taskUri, "ElideBuild"); + return /interface ElideBuild/.test(value) ? value : undefined; + }, 60_000, 500); + log("build extension API:", hover.replace(/\n/g, " ").slice(0, 160)); + assert.ok(!existsSync(path.join(sample, "tsconfig.json")), "no generated project config"); + const original = taskDoc.getText(); + const definitions = await vscode.commands.executeCommand<(vscode.Location | vscode.LocationLink)[]>( + "vscode.executeDefinitionProvider", taskUri, taskDoc.positionAt(original.indexOf("ElideBuild") + 2), + ); + assert.ok(definitions?.some((definition) => + ("targetUri" in definition ? definition.targetUri : definition.uri).fsPath.endsWith("/elide-build-typescript-plugin/build.d.ts")), + "Go to Definition opens the bundled build API"); + + const replace = async (document: vscode.TextDocument, text: string) => { + const edit = new vscode.WorkspaceEdit(); + edit.replace(document.uri, new vscode.Range(document.positionAt(0), document.positionAt(document.getText().length)), text); + assert.ok(await vscode.workspace.applyEdit(edit)); + }; + const complete = async (document: vscode.TextDocument, text: string, marker: string, expected: string[]) => { + await replace(document, text); + const position = document.positionAt(text.indexOf(marker) + marker.length); + const names = await waitFor(`completions ${expected.join(", ")}`, async () => { + const result = await vscode.commands.executeCommand("vscode.executeCompletionItemProvider", document.uri, position); + const labels = result?.items.map((item) => typeof item.label === "string" ? item.label : item.label.label) ?? []; + return expected.every((name) => labels.includes(name)) ? labels : undefined; + }, 30_000, 500); + log("completion:", expected.join(", "), `(${names.length} candidates)`); + }; + await complete(taskDoc, 'export default (build: ElideBuild) => { build. };\n', "build.", ["task", "project", "config", "layout"]); + await complete(taskDoc, 'export default ({ task }: ElideBuild) => { task("check", (ctx) => { ctx.tools. }); };\n', "ctx.tools.", ["classpath", "kotlinc", "javac", "jar", "format"]); + await replace(taskDoc, 'export default ({ task }: ElideBuild) => { task("check", { description: 42 }, (ctx) => { ctx.log("check"); }); };\n'); + await waitFor("invalid build task option diagnostic", () => errorsOf(taskUri).find((error) => /number.*string/.test(error.message)), 30_000, 500); + await replace(taskDoc, original); + await waitFor("valid build extension diagnostics", () => errorsOf(taskUri).length === 0 ? true : undefined, 30_000, 500); + + // A configured project uses the same API; JSDoc gives JavaScript actions contextual types too. + writeFileSync(path.join(sample, "tsconfig.json"), JSON.stringify({ compilerOptions: { strict: true, noEmit: true, allowJs: true, checkJs: true, target: "ES2022", module: "ESNext" }, include: ["tools/**/*"] })); + await vscode.commands.executeCommand("typescript.restartTsServer"); + await waitFor("configured ElideBuild API", async () => /interface ElideBuild/.test(await hoverAt(taskUri, "ElideBuild")) ? true : undefined, 60_000, 500); + const jsUri = vscode.Uri.file(path.join(sample, "tools/javascript.mjs")); + const jsDoc = await vscode.workspace.openTextDocument(jsUri); + await vscode.window.showTextDocument(jsDoc); + const jsOriginal = jsDoc.getText(); + await complete(jsDoc, '/** @param {ElideBuild} build */\nexport default function ({ task }) { task("js", ctx => { ctx. }); }\n', "ctx.", ["exec", "state", "tools", "log"]); + await replace(jsDoc, jsOriginal); + await waitFor("valid JavaScript extension diagnostics", () => errorsOf(jsUri).length === 0 ? true : undefined, 30_000, 500); + + // The contributed snippet produces real, typed extension code rather than requiring a hand-written reference. + const snippetUri = vscode.Uri.file(path.join(sample, "tools/snippet.ts")); + writeFileSync(snippetUri.fsPath, ""); + const snippetDoc = await vscode.workspace.openTextDocument(snippetUri); + await vscode.window.showTextDocument(snippetDoc); + await vscode.commands.executeCommand("editor.action.insertSnippet", { langId: "typescript", name: "Elide build extension" }); + assert.match(snippetDoc.getText(), /ElideBuild/); + assert.match(snippetDoc.getText(), /task\("greet"/); + await waitFor("snippet API hover", async () => /interface ElideBuild/.test(await hoverAt(snippetUri, "ElideBuild")) ? true : undefined, 30_000, 500); + await vscode.commands.executeCommand("leaveSnippet"); + await replace(snippetDoc, ""); + + const manifestUri = vscode.Uri.file(path.join(sample, "elide.pkl")); + const manifestDoc = await vscode.workspace.openTextDocument(manifestUri); + await vscode.window.showTextDocument(manifestDoc); + const links = await vscode.commands.executeCommand("vscode.executeLinkProvider", manifestUri); + assert.ok(links?.some((link) => link.target?.fsPath === taskUri.fsPath), "configured extension links to its source"); + assert.ok(links?.some((link) => link.target?.fsPath === jsUri.fsPath), "bare extension links to its source"); + assert.ok(!links?.some((link) => manifestDoc.getText(link.range) === "Hello"), "config strings are not links"); + log("manifest links:", links?.map((link) => manifestDoc.getText(link.range)).join(", ")); + const manifestOriginal = manifestDoc.getText(); + const outside = mkdtempSync(path.join(path.dirname(sample), "outside-extension-")); + const escapingLink = path.join(sample, "tools/outside.ts"); + try { + writeFileSync(path.join(outside, "tasks.ts"), "export default () => {};\n"); + symlinkSync(path.join(outside, "tasks.ts"), escapingLink); + await replace(manifestDoc, manifestOriginal.replace('from = "tools/tasks.ts"', 'from = "tools/outside.ts"')); + const editedLinks = await vscode.commands.executeCommand("vscode.executeLinkProvider", manifestUri); + assert.ok(!editedLinks?.some((link) => link.target?.fsPath === taskUri.fsPath), "navigation reflects the unsaved declaration"); + assert.ok(!editedLinks?.some((link) => link.target?.fsPath === escapingLink), "navigation rejects symlinks outside the source tree"); + assert.ok(editedLinks?.some((link) => link.target?.fsPath === jsUri.fsPath), "other valid declarations still link"); + } finally { + await replace(manifestDoc, manifestOriginal); + rmSync(escapingLink, { force: true }); + rmSync(outside, { recursive: true, force: true }); + } + + await vscode.commands.executeCommand("elide.sync"); + assert.ok(api.activity.getChildren().some((operation) => operation.label.startsWith("Sync ") && operation.state === "succeeded"), "real Elide sync succeeded"); + const lenses = await vscode.commands.executeCommand("vscode.executeCodeLensProvider", manifestUri); + const archive = lenses?.find((lens) => lens.command?.command === "elide.build" && lens.command.arguments?.[0]?.args?.[0] === "package-extension-tasks"); + assert.ok(archive?.command, "extension archive Build lens selects its actual packaging target"); + const build = async (args: string[]) => { + const { promise, resolve, reject } = Promise.withResolvers(); + const subscription = vscode.tasks.onDidEndTaskProcess((event) => { + if (event.execution.task.source === "elide") resolve(event.exitCode); + }); + const timeout = setTimeout(() => reject(new Error(`build ${args} timed out`)), 60_000); + try { + await vscode.commands.executeCommand("elide.build", { root: sample, args }); + assert.equal(await promise, 0); + } finally { + clearTimeout(timeout); + subscription.dispose(); + } + }; + await build(["greet", "greet-js"]); + await build(archive.command.arguments![0].args); + assert.ok(existsSync(path.join(sample, ".dev/artifacts/tasks.elx")), "archive produced by the code lens target"); + log("real Elide builds passed: greet, greet-js, package-extension-tasks"); + const buildTargets = { kind: "group", label: "Build targets", root: sample, group: "buildTargets" }; + const targetLabels = async () => (await api.explorer.getChildren(buildTargets) as { label: string }[]).map((node) => node.label); + assert.ok((await targetLabels()).includes("greet"), "extension tasks appear in the existing Build targets tree"); + await replace(taskDoc, original.replace("\n};", '\n task("added-on-save", { description: "Added while authoring" }, ctx => { ctx.log("saved extension"); });\n};')); + assert.ok(await taskDoc.save()); + await waitFor("saved extension target refresh", async () => (await targetLabels()).includes("added-on-save") ? true : undefined, 30_000, 500); + await build(["added-on-save"]); + await replace(taskDoc, original); + assert.ok(await taskDoc.save()); + assert.ok(!(await targetLabels()).includes("added-on-save"), "removed extension task disappears without re-sync"); + log("Build targets refreshed after task addition/removal on save"); + + // A separate non-Elide folder must not inherit the build globals. + const ordinary = mkdtempSync(path.join(path.dirname(sample), "ordinary-typescript-")); + try { + const ordinaryUri = vscode.Uri.file(path.join(ordinary, "ordinary.ts")); + writeFileSync(ordinaryUri.fsPath, "const build: ElideBuild = undefined;\n"); + assert.ok(vscode.workspace.updateWorkspaceFolders(vscode.workspace.workspaceFolders!.length, 0, { uri: vscode.Uri.file(ordinary) })); + const ordinaryDoc = await vscode.workspace.openTextDocument(ordinaryUri); + await vscode.window.showTextDocument(ordinaryDoc); + await waitFor("non-Elide API isolation", () => errorsOf(ordinaryUri).find((error) => /Cannot find name 'ElideBuild'/.test(error.message)), 30_000, 500); + log("non-Elide TypeScript project remains isolated"); + } finally { + rmSync(ordinary, { recursive: true, force: true }); + } + // Everything modified by the editor stayed unsaved; the runnable sources are unchanged. + assert.equal(readFileSync(taskUri.fsPath, "utf8"), original); + assert.equal(readFileSync(jsUri.fsPath, "utf8"), jsOriginal); + log("build-extension authoring scenario passed"); +} diff --git a/packages/vscode/test/integration/index.ts b/packages/vscode/test/integration/index.ts index 33ba91f..25c4bd6 100644 --- a/packages/vscode/test/integration/index.ts +++ b/packages/vscode/test/integration/index.ts @@ -20,10 +20,14 @@ import { runKomodoScenario } from "./komodo.js"; import { runKomodoDebugScenario } from "./komodoDebug.js"; import { runKomodoAndroidDebugScenario } from "./komodoAndroidDebug.js"; import { runWorkspaceScenario } from "./workspace.js"; +import { runBuildExtensionScenario } from "./buildExtension.js"; +import { runSelfBuildScenario } from "./selfBuild.js"; const sample = process.env.ELIDE_TEST_SAMPLE!; export async function run(): Promise { + if (process.env.ELIDE_TEST_SCENARIO === "self-build") return await runSelfBuildScenario(); + if (process.env.ELIDE_TEST_SCENARIO === "build-extension") return await runBuildExtensionScenario(); if (process.env.ELIDE_TEST_SCENARIO === "workspace") return await runWorkspaceScenario(); if (process.env.ELIDE_TEST_SCENARIO === "compose") return await runComposeScenario(); if (process.env.ELIDE_TEST_SCENARIO === "compose-komodo") return await runComposeKomodoScenario(); @@ -35,6 +39,9 @@ export async function run(): Promise { const folder = vscode.workspace.workspaceFolders?.[0]; assert.ok(folder, "sample workspace folder open"); assert.equal(folder.uri.fsPath, sample); + const elideConfig = vscode.workspace.getConfiguration("elide", folder); + const originalHome = elideConfig.inspect("home")?.globalValue; + const realElide = resolveElideDistribution({ explicitHome: elideConfig.get("home") }).bin; const workspaceJson = path.join(sample, "workspace.json"); // 1. Startup sync writes workspace.json and points the Kotlin LSP at a JDK. @@ -347,7 +354,7 @@ export async function run(): Promise { // source, where `main` is never called by the test and `greeting` is. Elide 1.6 moved the setting from // `testing.coverage` to a top-level `coverage` block. const manifestSource = readFileSync(manifest, "utf8"); - const elideVersion = parseElideVersion(execFileSync(resolveElideDistribution({}).bin, ["--version"], { encoding: "utf8" })); + const elideVersion = parseElideVersion(execFileSync(realElide, ["--version"], { encoding: "utf8" })); const legacyCoverage = elideVersion !== undefined && compareElideVersions(elideVersion, { major: 1, minor: 6, patch: 0 }) < 0; appendFileSync( manifest, @@ -625,7 +632,7 @@ export async function run(): Promise { await vscode.workspace.getConfiguration("elide").update("home", "/nonexistent/elide", vscode.ConfigurationTarget.Global); await vscode.commands.executeCommand("elide.sync"); assert.equal(readFileSync(workspaceJson, "utf8"), before, "workspace.json untouched after failed sync"); - await vscode.workspace.getConfiguration("elide").update("home", undefined, vscode.ConfigurationTarget.Global); + await vscode.workspace.getConfiguration("elide").update("home", originalHome, vscode.ConfigurationTarget.Global); log("failed sync left workspace.json intact"); // 6b. A manifest Elide rejects is reported in Elide's words, located on elide.pkl, and cleared by the next sync. @@ -661,8 +668,6 @@ export async function run(): Promise { // 7. An Elide older than the extension's minimum warns the user, and the sync still completes against it. const stubHome = path.join(tmpdir(), `elide-old-${process.pid}`); mkdirSync(path.join(stubHome, "bin"), { recursive: true }); - // The same distribution the extension resolves, so the stub wraps it whether or not `elide` is on this PATH. - const realElide = resolveElideDistribution({}).bin; writeFileSync( path.join(stubHome, "bin", "elide"), `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "1.4.0+stub"; exit 0; fi\nexec ${realElide} "$@"\n`, @@ -686,7 +691,7 @@ export async function run(): Promise { ); } finally { windowApi.showWarningMessage = realWarn; - await vscode.workspace.getConfiguration("elide").update("home", undefined, vscode.ConfigurationTarget.Global); + await vscode.workspace.getConfiguration("elide").update("home", originalHome, vscode.ConfigurationTarget.Global); rmSync(stubHome, { recursive: true, force: true }); } log("outdated Elide warning ok"); diff --git a/packages/vscode/test/integration/runner.ts b/packages/vscode/test/integration/runner.ts index 0dab3d0..3a26156 100644 --- a/packages/vscode/test/integration/runner.ts +++ b/packages/vscode/test/integration/runner.ts @@ -53,6 +53,7 @@ * `ELIDE_TEST_COMPONENTS_HOME` forwards an isolated component store as `ELIDE_COMPONENTS_HOME` to the extension host. * Install the candidate there explicitly before managed scenarios; omit all ELIDE_KOMODO_* path/JDK overrides. */ +import { execFileSync } from "node:child_process"; import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import path from "node:path"; @@ -64,21 +65,38 @@ const runfilesRoot = path.resolve(runfilesExtension, "..", ".."); // Runfiles are links into the output tree, and Node loads `dist/extension.js` at its real path: VS Code must know the // extension at that path too, or it attributes the extension's `vscode` API to no extension, and the calls that check // their caller's manifest (registering the `komodo-kotlin` debug adapter factory) fail. -const extensionDevelopmentPath = path.dirname(realpathSync(path.join(runfilesExtension, "package.json"))); -const extensionTestsPath = path.resolve(extensionDevelopmentPath, "dist-test", "index.js"); +const builtExtensionPath = path.dirname(realpathSync(path.join(runfilesExtension, "package.json"))); +let extensionTestsPath = path.resolve(builtExtensionPath, "dist-test", "index.js"); +// Smoke the shipped contents too: the development host loads the extracted VSIX, not the output-tree assets. +const extractedVsix = process.env.ELIDE_TEST_VSIX ? mkdtempSync(path.join(tmpdir(), "elide-vsix-")) : undefined; +if (extractedVsix) { + const archive = path.resolve(process.env.BUILD_WORKSPACE_DIRECTORY ?? process.cwd(), process.env.ELIDE_TEST_VSIX!); + execFileSync("unzip", ["-q", archive, "-d", extractedVsix]); +} +const extensionDevelopmentPath = extractedVsix ? path.join(extractedVsix, "extension") : builtExtensionPath; +if (extractedVsix) { + // VS Code scopes its API object to the importing extension. Keep the test bundle under the same + // extracted extension so dialog interception uses its API rather than the anonymous fallback API. + const testDirectory = path.join(extensionDevelopmentPath, ".integration-tests"); + mkdirSync(testDirectory); + const packagedTestsPath = path.join(testDirectory, "index.js"); + copyFileSync(extensionTestsPath, packagedTestsPath); + extensionTestsPath = packagedTestsPath; +} const vscodeExecutablePath = process.env.VSCODE_EXECUTABLE ?? "/Applications/Visual Studio Code.app/Contents/MacOS/Code"; -const scenarios = ["workspace", "compose", "compose-komodo", "compose-agent", "android", "komodo", "komodo-debug", "komodo-android-debug"] as const; +const scenarios = ["workspace", "compose", "compose-komodo", "compose-agent", "android", "komodo", "komodo-debug", "komodo-android-debug", "build-extension", "self-build"] as const; const scenario = scenarios.find((name) => name === process.env.ELIDE_TEST_SCENARIO) ?? "ktjvm"; const composeScenario = scenario === "compose" || scenario === "compose-komodo" || scenario === "compose-agent"; /** The Komodo preview engine's scenarios. */ const komodoPreview = scenario === "compose-komodo" || (scenario === "compose-agent" && process.env.ELIDE_COMPOSE_ENGINE === "komodo"); const androidScenario = scenario === "android" || scenario === "komodo-android-debug"; /** Komodo's scenarios: the folder on Komodo's language server, every other extension disabled. */ -const komodo = scenario === "komodo" || scenario === "komodo-debug" || scenario === "komodo-android-debug"; +const komodo = scenario === "komodo" || scenario === "komodo-debug" || scenario === "komodo-android-debug" || scenario === "self-build"; /** Every scenario on Komodo's language server: Komodo's own, and the Komodo preview engine's (it discovers previews through it). */ const komodoServer = komodo || komodoPreview; +const buildExtension = scenario === "build-extension"; if (androidScenario) { const missing = ["ELIDE_ANDROID_APP_SAMPLE", "ELIDE_ANDROID_APP_MEMBER", "ELIDE_ANDROID_APP_ARTIFACT", "ELIDE_ANDROID_APP_ID"] .filter((name) => !process.env[name]); @@ -87,11 +105,14 @@ if (androidScenario) { if (scenario === "komodo-android-debug" && !process.env.ELIDE_ANDROID_DEVICE) { throw new Error("Android debugging requires ELIDE_ANDROID_DEVICE naming a ready disposable emulator"); } +if (scenario === "self-build" && !process.env.ELIDE_SELF_BUILD_ROOT) { + throw new Error("Self-build verification requires ELIDE_SELF_BUILD_ROOT naming the Elide self-build checkout"); +} // The user's extensions dir is used as-is so the already-installed JetBrains extension (1.2 GB) is reused. Komodo's // scenarios run without it (it is disabled for the instance), so it need not be installed. const extensionsDir = path.join(homedir(), ".vscode", "extensions"); -if (!komodo && !readdirSync(extensionsDir).some((d) => d.startsWith("jetbrains.kotlin-server-"))) throw new Error(`JetBrains.kotlin-server is not installed in ${extensionsDir}`); +if (!komodo && !buildExtension && !readdirSync(extensionsDir).some((d) => d.startsWith("jetbrains.kotlin-server-"))) throw new Error(`JetBrains.kotlin-server is not installed in ${extensionsDir}`); // Keep macOS IPC paths short without overriding TMPDIR: the Kotlin client and JVM must agree on their port-file directory. const scratchRoot = process.platform === "darwin" ? "/tmp" : tmpdir(); @@ -114,6 +135,11 @@ writeFileSync( "telemetry.telemetryLevel": "off", // An emulator the test boots runs headless. ...(androidScenario ? { "elide.android.emulatorWindow": false } : {}), + ...(buildExtension ? { + "elide.sync.onStartup": false, + "elide.kotlinLsp.writeWorkspaceJson": false, + "typescript.tsserver.log": "verbose", + } : {}), ...(komodoPreview ? { "elide.android.composePreview.engine": "komodo", } : {}), @@ -137,14 +163,15 @@ writeFileSync( ); // Work on a scratch copy of the sample so the test can mutate elide.pkl freely; the Android sample stays in place. -const inPlace = composeScenario || (scenario === "komodo" && process.env.ELIDE_KOMODO_SAMPLE === "android"); -const sample = inPlace - ? composeSampleDirectory() - : mkdtempSync(path.join(realpathSync(scratchRoot), `elide-${scenario}-`)); +const inPlace = composeScenario || scenario === "self-build" || (scenario === "komodo" && process.env.ELIDE_KOMODO_SAMPLE === "android"); +const sample = scenario === "self-build" + ? realpathSync(process.env.ELIDE_SELF_BUILD_ROOT!) + : inPlace ? composeSampleDirectory() : mkdtempSync(path.join(realpathSync(scratchRoot), `elide-${scenario}-`)); let restoreComposeSettings: (() => void) | undefined; if (scenario === "workspace") prepareWorkspaceSample(); else if (composeScenario) prepareComposeSample(); else if (androidScenario) prepareAndroidSample(); +else if (buildExtension) copyFiles(path.join(runfilesRoot, "samples", "build-extension"), sample); else if (komodo) { if (!inPlace) copyFiles(path.join(runfilesRoot, "samples", "ktjvm"), sample); } else prepareKtjvmSample(); @@ -302,7 +329,7 @@ try { // (the JetBrains server would contribute its own diagnostics, locations, `kotlin` grammar and debugger; others // their own formatters and providers), leaving this extension under development. ELIDE_KOMODO_COEXIST keeps them // enabled. - ...(komodoServer && !process.env.ELIDE_KOMODO_COEXIST ? ["--disable-extensions"] : [])], + ...((komodoServer && !process.env.ELIDE_KOMODO_COEXIST) || buildExtension ? ["--disable-extensions"] : [])], extensionTestsEnv: { ELIDE_TEST_SAMPLE: sample, ELIDE_TEST_SCENARIO: scenario, @@ -316,7 +343,8 @@ try { if (!process.env.KEEP_TEST_DIRS) { rmSync(userDataDir, { recursive: true, force: true }); if (!inPlace) rmSync(sample, { recursive: true, force: true }); + if (extractedVsix) rmSync(extractedVsix, { recursive: true, force: true }); } else { - console.log("kept", { userDataDir, sample }); + console.log("kept", { userDataDir, sample, extractedVsix }); } } diff --git a/packages/vscode/test/integration/selfBuild.ts b/packages/vscode/test/integration/selfBuild.ts new file mode 100644 index 0000000..edfdcc8 --- /dev/null +++ b/packages/vscode/test/integration/selfBuild.ts @@ -0,0 +1,148 @@ +/** Exercises the self-build checkout in place; all source edits stay unsaved and are reverted. */ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import * as vscode from "vscode"; +import type { ElideExtensionApi } from "../../src/extension.js"; +import { errorsOf, hoverAt, log, waitFor } from "./helpers.js"; + +export async function runSelfBuildScenario(): Promise { + const root = process.env.ELIDE_TEST_SAMPLE!; + const files = [ + "packages/base/main/dev/elide/core/jvm/JvmLogger.kt", + "packages/jvmlang/main/dev/elide/runtime/kotlin/KotlinRunnable.kt", + "packages/base/test/dev/elide/core/jvm/JvmLoggerTest.kt", + ]; + const originals = new Map(files.map((file) => [file, readFileSync(path.join(root, file), "utf8")])); + const failures: string[] = []; + const check = async (name: string, run: () => Promise) => { + const started = Date.now(); + try { await run(); log(`PASS ${name} (${Date.now() - started} ms)`); } + catch (error) { failures.push(`${name}: ${error}`); log(`FAIL ${name}: ${error}`); } + }; + const at = (document: vscode.TextDocument, text: string, delta = 0) => { + const offset = document.getText().indexOf(text); + assert.ok(offset >= 0, `${text} in ${document.uri.fsPath}`); + return document.positionAt(offset + delta); + }; + const open = async (file: string) => vscode.workspace.openTextDocument(vscode.Uri.file(path.join(root, file))); + const extension = vscode.extensions.getExtension("elide.elide")!; + const api = await extension.activate(); + await check("self-build synchronization", async () => { + const sync = await waitFor("self-build sync", () => api.activity.getChildren().find((node) => node.label.startsWith("Sync ") && !node.running), 600_000, 1_000); + const output = api.activity.provideTextDocumentContent(api.activity.uriOf(sync)); + log(output); + assert.equal(sync.state, "succeeded", output); + const pending = [...sync.children]; + while (pending.length) { + const step = pending.pop()!; + assert.notEqual(step.state, "failed", `${step.path.join(" > ")} failed; see the sync log above`); + pending.push(...step.children); + } + }); + const logger = await open(files[0]!); + const runnable = await open(files[1]!); + const test = await open(files[2]!); + await vscode.window.showTextDocument(logger, { preview: false }); + await waitFor("self-build Kotlin analysis", async () => { + const hovers = await vscode.commands.executeCommand("vscode.executeHoverProvider", logger.uri, at(logger, "class JvmLogger", 6)); + return hovers?.length ? hovers : undefined; + }, 240_000, 1_000); + + try { + for (const document of [logger, runnable, test]) { + await vscode.window.showTextDocument(document, { preview: false }); + await check(`resolved CodeLenses: ${path.basename(document.uri.fsPath)}`, async () => { + const lenses = await vscode.commands.executeCommand("vscode.executeCodeLensProvider", document.uri, 1_000); + const reference = lenses.find((lens) => lens.command?.command === "komodo.showReferences"); + assert.ok(reference?.command, `resolved reference lens among ${JSON.stringify(lenses)}`); + assert.ok(lenses.every((lens) => lens.command), "every returned lens resolves"); + log("lenses", lenses.map((lens) => `${lens.range.start.line + 1}: ${lens.command?.title}`).join(" | ")); + await vscode.commands.executeCommand(reference.command.command, ...(reference.command.arguments ?? [])); + await vscode.commands.executeCommand("closeReferenceSearch"); + }); + await check(`semantic highlighting: ${path.basename(document.uri.fsPath)}`, async () => { + const declaration = document === runnable ? "interface KotlinRunnable" : document === logger ? "class JvmLogger" : "class JvmLoggerTest"; + const position = at(document, declaration, declaration.indexOf(" ") + 1); + const legend = await vscode.commands.executeCommand("vscode.provideDocumentSemanticTokensLegend", document.uri); + const full = await vscode.commands.executeCommand("vscode.provideDocumentSemanticTokens", document.uri); + const ranged = await vscode.commands.executeCommand("vscode.provideDocumentRangeSemanticTokens", + document.uri, new vscode.Range(0, 0, document.lineCount, 0)); + for (const tokens of [full, ranged]) { + let line = 0; + let character = 0; + let declaredType: string | undefined; + for (let index = 0; index < tokens.data.length; index += 5) { + line += tokens.data[index]!; + character = tokens.data[index] === 0 ? character + tokens.data[index + 1]! : tokens.data[index + 1]!; + if (line === position.line && character === position.character) declaredType = legend.tokenTypes[tokens.data[index + 3]!]; + } + assert.equal(declaredType, document === runnable ? "interface" : "class"); + } + }); + await check(`diagnostics: ${path.basename(document.uri.fsPath)}`, async () => { + // Opening the document initiates diagnostics; CodeLens resolution above waits for the same analysis. + await delay(1_000); + assert.deepEqual(errorsOf(document.uri).map((d) => `${d.range.start.line + 1}: ${d.message}`), []); + }); + } + await check("cross-module definition", async () => { + const definitions = await vscode.commands.executeCommand<(vscode.Location | vscode.LocationLink)[]>("vscode.executeDefinitionProvider", runnable.uri, at(runnable, "Precompiler.BundleInfo")); + const targets = definitions.map((item) => ("targetUri" in item ? item.targetUri : item.uri).fsPath); + assert.ok(targets.some((target) => target.includes("/packages/base/") && target.endsWith("Precompiler.kt")), targets.join(", ")); + }); + await check("library hover and source navigation", async () => { + const position = at(runnable, "Function("vscode.executeDefinitionProvider", runnable.uri, position); + const target = definitions.map((item) => "targetUri" in item ? item.targetUri : item.uri).find((uri) => uri.scheme === "komodo-library"); + assert.ok(target, JSON.stringify(definitions)); + assert.match((await vscode.workspace.openTextDocument(target)).getText(), /interface Function/); + }); + await check("unsaved declaration edits, diagnostics and lens recovery", async () => { + const editor = await vscode.window.showTextDocument(logger, { preview: false }); + const end = logger.positionAt(logger.getText().length); + assert.ok(await editor.edit((edit) => edit.insert(end, "\nprivate fun selfBuildEditorProbe(): String = 123\n"))); + await waitFor("type mismatch after unsaved edit", () => errorsOf(logger.uri).find((diagnostic) => diagnostic.range.start.line >= end.line && /type mismatch|return type/i.test(diagnostic.message)), 30_000, 100); + const lenses = await vscode.commands.executeCommand("vscode.executeCodeLensProvider", logger.uri, 1_000); + assert.ok(lenses.some((lens) => lens.range.contains(at(logger, "selfBuildEditorProbe")) && lens.command?.command === "komodo.showReferences")); + await vscode.commands.executeCommand("workbench.action.files.revert"); + await waitFor("diagnostics clear after revert", () => errorsOf(logger.uri).length === 0, 30_000, 100); + const restored = await vscode.commands.executeCommand("vscode.executeCodeLensProvider", logger.uri, 1_000); + assert.ok(restored.some((lens) => lens.range.contains(at(logger, "class JvmLogger", 6)) && lens.command?.command === "komodo.showReferences")); + }); + await check("build-extension TypeScript authoring", async () => { + const document = await open("tools/build/tasks/jvm.ts"); + await vscode.window.showTextDocument(document, { preview: false }); + const position = at(document, "ElideBuild"); + await waitFor("ElideBuild API type", async () => { + const result = await hoverAt(document.uri, "ElideBuild"); + return /interface ElideBuild/.test(result) ? result : undefined; + }, 60_000, 500); + const definitions = await vscode.commands.executeCommand<(vscode.Location | vscode.LocationLink)[]>( + "vscode.executeDefinitionProvider", document.uri, position); + const targets = definitions.map((item) => ("targetUri" in item ? item.targetUri : item.uri).fsPath); + assert.ok(targets.some((target) => target.endsWith("/build.d.ts")), targets.join(", ")); + await delay(1_000); + assert.deepEqual(errorsOf(document.uri).map((diagnostic) => diagnostic.message), []); + }); + await check("workspace task discovery", async () => { + const tasks = await vscode.tasks.fetchTasks({ type: "elide" }); + const names = tasks.map((task) => task.name); + for (const project of ["packages/generated", "packages/jvmlang", "packages/cli", "packages/jvmtools"]) { + assert.ok(tasks.some((task) => task.definition.command === "build" && task.definition.project === project), + `build task for ${project} among ${names.join(", ")}`); + } + }); + } finally { + for (const document of [logger, runnable, test]) { + if (document.isDirty) { + await vscode.window.showTextDocument(document); + await vscode.commands.executeCommand("workbench.action.files.revert"); + } + } + for (const [file, original] of originals) assert.equal(readFileSync(path.join(root, file), "utf8"), original, `${file} unchanged on disk`); + } + assert.deepEqual(failures, [], failures.join("\n")); +} diff --git a/samples/build-extension/BUILD.bazel b/samples/build-extension/BUILD.bazel new file mode 100644 index 0000000..784745b --- /dev/null +++ b/samples/build-extension/BUILD.bazel @@ -0,0 +1,14 @@ +# Guest extension sources are run by Elide, not compiled by Bazel. +# gazelle:exclude tools + +filegroup( + name = "build-extension", + srcs = glob( + ["**"], + exclude = [ + "BUILD.bazel", + "workspace.json", + ], + ), + visibility = ["//packages/vscode:__pkg__"], +) diff --git a/samples/build-extension/elide.pkl b/samples/build-extension/elide.pkl new file mode 100644 index 0000000..007db9b --- /dev/null +++ b/samples/build-extension/elide.pkl @@ -0,0 +1,21 @@ +amends "elide:project.pkl" + +import "elide:Extensions.pkl" as Extensions + +name = "build-extension-sample" +version = "1.0.0" + +extensions { + new { + from = "tools/tasks.ts" + config { greeting = "Hello" } + } + "tools/javascript.mjs" +} + +artifacts { + ["tasks"] = new Extensions.ExtensionArchive { + entrypoint = "tools/tasks.ts" + srcs { "tools/**/*.ts" } + } +} diff --git a/samples/build-extension/tools/javascript.mjs b/samples/build-extension/tools/javascript.mjs new file mode 100644 index 0000000..28e1e8d --- /dev/null +++ b/samples/build-extension/tools/javascript.mjs @@ -0,0 +1,7 @@ +// @ts-check +/** @param {ElideBuild} build */ +export default function ({ task }) { + task("greet-js", { description: "Greet from JavaScript" }, (ctx) => { + ctx.log("JavaScript extension", ctx.project.name); + }); +} diff --git a/samples/build-extension/tools/tasks.ts b/samples/build-extension/tools/tasks.ts new file mode 100644 index 0000000..b9ab517 --- /dev/null +++ b/samples/build-extension/tools/tasks.ts @@ -0,0 +1,5 @@ +export default ({ task }: ElideBuild) => { + task("greet", { description: "Greet from an Elide build extension" }, (ctx) => { + ctx.log(`${ctx.config.greeting ?? "Hello"} from ${ctx.project.name}`); + }); +}; From e9602223977047fdacf041767001d1483dbe23b2 Mon Sep 17 00:00:00 2001 From: Dario Valdespino Date: Tue, 3 Nov 2026 13:39:21 -0300 Subject: [PATCH 2/2] build: refresh authoring manifest cache --- .../npm_translate_lock_LTE4Nzc1MDcwNjU= | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU= b/.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU= index 8e5cefc..c885c46 100755 --- a/.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU= +++ b/.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU= @@ -3,6 +3,6 @@ # This file should be checked into version control along with the pnpm-lock.yaml file. package.json=1645056029 packages/core/package.json=-1407934132 -packages/vscode/package.json=1052087398 +packages/vscode/package.json=402694317 pnpm-lock.yaml=1115513514 pnpm-workspace.yaml=-1673009026