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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ and a pinned (`&version=`) link.

Opening a folder that contains `elide.pkl` runs a **sync**:

1. `elide manifest` — the resolved project manifest as JSON (source sets, Kotlin compiler options, entrypoints).
1. `elide project export` (`elide manifest` before Elide 1.6) — the resolved project manifest as JSON (source sets,
Kotlin compiler options, entrypoints).
2. `elide install` — only when the lockfile (`.dev/elide.lock*.bin`) is older than the manifest.
3. `elide classpath <source set>:compile` for every compilable source set.
4. `workspace.json` is written at the workspace folder root in the Kotlin LSP's JSON workspace format (one module per
Expand Down
6 changes: 3 additions & 3 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@ is referenced anywhere in this package.

| Module | Responsibility |
| --- | --- |
| `elide.ts` | Locate an Elide distribution (explicit home, `$ELIDE_HOME`, platform install locations, `PATH`) and run CLI commands (`manifest`, `install`, `classpath`). |
| `manifest.ts` | Decode `elide manifest` JSON into a typed manifest: source sets, Kotlin compiler options, entrypoints, JVM settings. |
| `elide.ts` | Locate an Elide distribution (explicit home, `$ELIDE_HOME`, platform install locations, `PATH`) and run CLI commands (`project export`, `install`, `classpath`). |
| `manifest.ts` | Decode `elide project export` JSON into a typed manifest: source sets, Kotlin compiler options, entrypoints, JVM settings. |
| `sourceRoots.ts`, `libraries.ts` | Derive source roots and resolve classpath entries into libraries with attached `-sources.jar` / `-javadoc.jar`. |
| `jdk.ts` | Discover installed JDKs (SDKMAN, macOS, Linux, Windows locations) and pick one matching the project's `jvm.target`. |
| `model.ts` | The resulting project model: projects, modules, libraries, SDK. |
| `workspace.ts` | Elide workspaces: members declared by a manifest, sibling artifacts on a member's classpath and the source sets behind them. |
| `pkl.ts` | Read and edit `elide.pkl` as text, for what `elide manifest` cannot answer (artifact source positions, the workspace above a folder). |
| `pkl.ts` | Read and edit `elide.pkl` as text, for what `elide project export` cannot answer (artifact source positions, the workspace above a folder). |
| `kotlinLsp.ts` | Emit the JSON workspace (`workspace.json`) consumed by the JetBrains Kotlin language server. |

## Usage
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const IMAGE_TYPE = /^\s*type\s*=\s*"([^"]*)"/;
/**
* Artifacts declared in the text of an `elide.pkl`, in source order.
*
* The manifest is read as text rather than through `elide manifest`, whose JSON carries no artifacts and no source
* The manifest is read as text rather than through `elide project export`, whose JSON carries no artifacts and no source
* positions; the caller needs both the line of each entry and the settings that decide the output path.
*/
export function parseManifestArtifacts(text: string): ManifestArtifact[] {
Expand Down
33 changes: 31 additions & 2 deletions packages/core/src/elide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,10 +256,34 @@ export class ElideCli {
return stdout.trim().split("\n")[0]?.trim() ?? "";
}

/** `elide manifest` → decoded manifest JSON. */
/**
* `elide project export` → decoded manifest JSON, falling back to `elide manifest` on a CLI that predates the
* export command (before 1.6). The first attempt's stderr is held back so that fallback leaves no usage error behind.
*/
async manifest(opts: RunOptions = {}): Promise<Manifest> {
const held: string[] = [];
const replay = () => {
for (const line of held) opts.onLine?.(line, true);
};
try {
const manifest = await this.readManifest(["project", "export"], {
...opts,
onLine: (line, stderr) => (stderr ? held.push(line) : opts.onLine?.(line, false)),
});
replay();
return manifest;
} catch (e) {
if (!isUnknownSubcommand(e)) {
replay();
throw e;
}
}
return this.readManifest(["manifest"], opts);
}

private async readManifest(command: readonly string[], opts: RunOptions): Promise<Manifest> {
let json = "";
await this.run(["manifest", ...elideFlagArgs(this.flags)], {
await this.run([...command, ...elideFlagArgs(this.flags)], {
...opts,
onLine: (line, stderr) => {
if (!stderr) json += `${line}\n`;
Expand Down Expand Up @@ -330,6 +354,11 @@ export class ElideCli {
}
}

/** Whether `e` is the CLI's usage error for a subcommand it does not have (exit 2, `unrecognized subcommand`). */
export function isUnknownSubcommand(e: unknown): boolean {
return e instanceof ElideCommandFailedError && e.exitCode === 2 && e.stderr.includes("unrecognized subcommand");
}

/** Split a platform-native classpath string; relative entries are resolved against `root`. */
export function parseClasspath(text: string, root: string, delimiter: string = path.delimiter): string[] {
return text
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export class ElideCommandFailedError extends Error {
}
}

/** `elide manifest` printed something the decoder does not understand. */
/** `elide project export` printed something the decoder does not understand. */
export class ManifestParseError extends Error {
constructor(message: string, override readonly cause?: unknown) {
super(`Failed to parse Elide project manifest: ${message}`);
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export {
isLockfileCurrent,
isLockfileName,
isNestedUnder,
isUnknownSubcommand,
killProcessTree,
lockfileDigest,
lockfilesIn,
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/manifest.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ManifestParseError } from "./errors.js";

/**
* Decoded `elide manifest` output.
* Decoded `elide project export` output.
*
* The CLI serializes its Pkl-derived project model with kotlinx.serialization (`encodeDefaults=false`,
* `explicitNulls=false`, discriminator `"@type"`), so absent keys mean schema defaults and unions arrive as objects
Expand Down Expand Up @@ -209,7 +209,7 @@ function decodeEngine(v: unknown): string | undefined {
return undefined;
}

/** Decode the JSON printed by `elide manifest`. Throws {@link ManifestParseError} on malformed input. */
/** Decode the JSON printed by `elide project export` (`elide manifest` before Elide 1.6). Throws {@link ManifestParseError} on malformed input. */
export function decodeManifest(text: string): Manifest {
let raw: unknown;
try {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export async function buildProjectModels(cli: ElideCli, manifest: Manifest, opts
opts.onProgress?.("Querying Elide version");
const elideVersion = await cli.version(run);

// `elide manifest` has already validated the member list at the root: every entry is a directory below it holding
// `elide project export` has already validated the member list at the root: every entry is a directory below it holding
// a manifest, and no two members share a name.
const projects: ProjectSpec[] = [{ name: projectName(manifest, root), root, cli, manifest }];
for (const entry of manifest.workspaceMembers) {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/pkl.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Reading and editing `elide.pkl` as text.
*
* `elide manifest` answers for a project the CLI can already load, which is not every question an editor asks: the
* `elide project export` answers for a project the CLI can already load, which is not every question an editor asks: the
* artifacts a manifest declares carry no source positions in that JSON, and the workspace above a folder has to be
* found before any project is loaded. Both are read from the manifest text instead, with the little of Pkl's syntax
* those questions need — braces, string literals and `//` comments.
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export function artifactProjectRoot(entry: string): string | undefined {
* The members a manifest declares, read from its text: `workspace { members { "model" "cli" } }`.
*
* Reading the text answers for a directory no project has been loaded from — the workspace above the folder an
* editor was pointed at — which `elide manifest` cannot be asked about without loading that project first.
* editor was pointed at — which `elide project export` cannot be asked about without loading that project first.
*/
export function parseWorkspaceMembers(text: string): string[] {
const workspace = pklBlock(text, "workspace");
Expand Down
69 changes: 68 additions & 1 deletion packages/core/test/elide.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { afterAll, describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs";
import { chmodSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import {
ElideCli,
ElideCommandFailedError,
ElideNotFoundError,
InvalidElideHomeError,
distributionAt,
lockfileDigest,
lockfilesIn,
outermostManifests,
Expand Down Expand Up @@ -115,3 +118,67 @@ describe("lockfileDigest", () => {
expect(await lockfilesIn(path.dirname(projectDev()))).toEqual([]);
});
});

/**
* Stand-ins for the CLI, with the exit codes and usage errors of the real ones: 1.6 removed `manifest` in favour of
* `project export`, which 1.5 does not have. Each prints its argv to stderr after the manifest.
*/
const MANIFEST_JSON = '{"name":"demo"}';
const ELIDE_1_6 = `#!/bin/sh
case "$1 $2" in
"project export") echo '${MANIFEST_JSON}'; echo "argv: $*" >&2 ;;
manifest*) echo 'elide manifest: removed; use \`elide project export\` instead' >&2; exit 2 ;;
esac
`;
const ELIDE_1_5 = `#!/bin/sh
case "$1 $2" in
"project export") printf "error: unrecognized subcommand 'export'\\n\\nUsage: elide project [-p --project <PATH>] [SUBCOMMAND]\\n" >&2; exit 2 ;;
manifest*) echo '${MANIFEST_JSON}'; echo "argv: $*" >&2 ;;
esac
`;
const ELIDE_BROKEN_PROJECT = `#!/bin/sh
echo "Pkl Error: Cannot find module 'elide:nope.pkl'" >&2
exit 1
`;

describe("ElideCli.manifest", () => {
const dirs: string[] = [];
afterAll(() => {
for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
});
const cliFor = (script: string, flags: string[] = []): ElideCli => {
const home = mkdtempSync(path.join(tmpdir(), "elide-home-"));
dirs.push(home);
mkdirSync(path.join(home, "bin"));
writeFileSync(path.join(home, "bin", "elide"), script);
chmodSync(path.join(home, "bin", "elide"), 0o755);
return new ElideCli(distributionAt(home), home, flags);
};
const manifestOf = async (cli: ElideCli) => {
const stderr: string[] = [];
const manifest = await cli.manifest({ onLine: (line, isStderr) => isStderr && stderr.push(line) });
return { manifest, stderr };
};

test("reads `elide project export` on Elide 1.6", async () => {
const { manifest, stderr } = await manifestOf(cliFor(ELIDE_1_6, ["release"]));
expect(manifest.name).toBe("demo");
expect(stderr).toEqual(["argv: project export -f release"]);
});

test("falls back to `elide manifest` on a CLI without `project export`, hiding the usage error", async () => {
const { manifest, stderr } = await manifestOf(cliFor(ELIDE_1_5, ["release"]));
expect(manifest.name).toBe("demo");
expect(stderr).toEqual(["argv: manifest -f release"]);
});

test("any other failure of `project export` is reported as is", async () => {
const stderr: string[] = [];
const failure = await cliFor(ELIDE_BROKEN_PROJECT)
.manifest({ onLine: (line, isStderr) => isStderr && stderr.push(line) })
.catch((e: unknown) => e);
expect(failure).toBeInstanceOf(ElideCommandFailedError);
expect((failure as ElideCommandFailedError).args).toEqual(["project", "export"]);
expect(stderr).toEqual(["Pkl Error: Cannot find module 'elide:nope.pkl'"]);
});
});
4 changes: 2 additions & 2 deletions packages/core/test/workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,13 +149,13 @@ describe("findEnclosingWorkspace", () => {
});

/**
* A stand-in for the CLI: answers `manifest` and `classpath` from files in the directory it is invoked in, the way
* A stand-in for the CLI: answers `project export` and `classpath` from files in the directory it is invoked in, the way
* the real one answers for the project it is focused on, and records every `install` in the workspace root.
*/
const FAKE_ELIDE = `#!/bin/sh
case "$1" in
--version) echo "1.5.4+test" ;;
manifest) cat .fake/manifest.json ;;
project) cat .fake/manifest.json ;;
classpath) cat ".fake/classpath-\${2%%:*}.txt" ;;
install) echo "$PWD" >> "$FAKE_INSTALL_LOG" ;;
esac
Expand Down
2 changes: 1 addition & 1 deletion packages/vscode/media/walkthrough/sync.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
## Sync with the Kotlin LSP

A sync runs `elide manifest`, `elide install` and `elide classpath`, then writes a `workspace.json` at the workspace folder root. The JetBrains Kotlin LSP imports that file, which is what makes Kotlin and Java symbols, library sources and Go to Definition work.
A sync runs `elide project export`, `elide install` and `elide classpath`, then writes a `workspace.json` at the workspace folder root. The JetBrains Kotlin LSP imports that file, which is what makes Kotlin and Java symbols, library sources and Go to Definition work.

It happens when the window opens and whenever `elide.pkl` or the lockfile changes; the status-bar item shows the current state and opens the Elide menu.

Expand Down
2 changes: 1 addition & 1 deletion packages/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"capabilities": {
"untrustedWorkspaces": {
"supported": false,
"description": "Syncing a project runs the workspace's Elide CLI (`elide manifest`, `elide install`, `elide classpath`), so an untrusted workspace could execute arbitrary code."
"description": "Syncing a project runs the workspace's Elide CLI (`elide project export`, `elide install`, `elide classpath`), so an untrusted workspace could execute arbitrary code."
},
"virtualWorkspaces": {
"supported": false,
Expand Down
2 changes: 1 addition & 1 deletion packages/vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ function registerWatchers(context: vscode.ExtensionContext, workspace: ElideWork
const project = workspace.addProject(uri);
if (!project) {
// A manifest nested in an Elide workspace can be a member its root declares — or the one whose absence failed
// the last sync — and only a sync of the root can tell: the member list is whatever `elide manifest` says.
// the last sync — and only a sync of the root can tell: the member list is whatever `elide project export` says.
const located = workspace.locate(uri);
const owner = located ? workspace.projectFor(path.dirname(located.fsPath)) : undefined;
const inWorkspace = owner !== undefined && (owner.workspaceRoot !== undefined || (owner.model?.members.length ?? 0) > 0);
Expand Down
Loading