Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions .bazelignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ packages/core/node_modules
packages/vscode/node_modules
samples/ktjvm/.dev
samples/workspace/.dev
samples/build-extension/.dev
48 changes: 48 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,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
Expand Down Expand Up @@ -126,6 +158,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.

Expand Down
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<artifact key>` 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 |
Expand Down
1 change: 1 addition & 0 deletions packages/core/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/artifacts.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
Expand Down
145 changes: 145 additions & 0 deletions packages/core/src/buildExtensions.ts
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
18 changes: 17 additions & 1 deletion packages/core/test/artifacts.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading