дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
1198 рядки
52 KiB
TypeScript
1198 рядки
52 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
||
import * as projectRoots from "@/platform/projectRoots";
|
||
import type { HierarchyItem, RunConfig } from "@/platform/ipc";
|
||
import {
|
||
innermostTarget,
|
||
nodeTypeStripping,
|
||
OPTIMISTIC,
|
||
produceContext,
|
||
produceContextChoices,
|
||
produceDirectory,
|
||
produceFile,
|
||
shebang,
|
||
shellWord,
|
||
fileTargets,
|
||
type ContextInput,
|
||
type LocatedTool,
|
||
type ToolEnv,
|
||
} from "./producers";
|
||
import { clearInheritedMain, resolveCandidates, unresolvedCandidates } from "./inheritedMain";
|
||
|
||
afterEach(() => clearInheritedMain());
|
||
|
||
const ROOT = "/work/app";
|
||
const superItem: Omit<HierarchyItem, "name" | "path"> = {
|
||
kind: "class",
|
||
detail: null,
|
||
range: { start: { line: 0, col: 0 }, end: { line: 1, col: 0 } },
|
||
selectionRange: { start: { line: 0, col: 0 }, end: { line: 0, col: 1 } },
|
||
fromRanges: [],
|
||
};
|
||
const SCRATCH = "/Users/me/Library/Application Support/ai.l0ng.openidea/scratches";
|
||
|
||
const cfg = (over: Partial<RunConfig> & { id: string; kind: string }): RunConfig => ({
|
||
name: over.id,
|
||
command: over.kind,
|
||
args: [],
|
||
cwd: null,
|
||
env: {},
|
||
detected: true,
|
||
isTest: false,
|
||
debugAdapter: null,
|
||
...over,
|
||
});
|
||
|
||
/** Tools at `/bin/<name>` with optional versions; names not listed are not installed. */
|
||
function env(tools: Record<string, string | null>, extra: Partial<ToolEnv> = {}): ToolEnv {
|
||
return {
|
||
tool: (n): LocatedTool | null => (n in tools ? { path: `/bin/${n}`, version: tools[n] ?? null } : null),
|
||
compileDir: "/tmp/openidea-run",
|
||
...extra,
|
||
};
|
||
}
|
||
|
||
const ALL = env({
|
||
node: "24.11.0",
|
||
python3: null,
|
||
bash: null,
|
||
zsh: null,
|
||
sh: null,
|
||
ruby: null,
|
||
php: null,
|
||
perl: null,
|
||
lua: null,
|
||
Rscript: null,
|
||
go: null,
|
||
rustc: "1.85.0",
|
||
java: null,
|
||
kotlin: null,
|
||
swift: null,
|
||
clang: null,
|
||
"clang++": null,
|
||
dart: null,
|
||
elixir: null,
|
||
"scala-cli": null,
|
||
deno: "2.1.0",
|
||
bun: "1.1.0",
|
||
});
|
||
|
||
const scratch = (name: string, content?: string, line?: number): ContextInput => ({
|
||
path: `${SCRATCH}/${name}`,
|
||
root: ROOT,
|
||
configs: [],
|
||
content,
|
||
line,
|
||
});
|
||
|
||
const run = (input: ContextInput, e: ToolEnv = ALL) => {
|
||
const p = produceContext(input, e);
|
||
return p?.config ? { name: p.name, command: p.config.command, args: p.config.args, cwd: p.config.cwd } : p;
|
||
};
|
||
|
||
describe("single-file producers (scratches)", () => {
|
||
it("JavaScript: node <file> from the file's directory, debuggable with js-debug", () => {
|
||
const p = produceContext(scratch("hi.js", "console.log(1)"), ALL)!;
|
||
expect(p.name).toBe("hi.js");
|
||
expect(p.config).toMatchObject({ command: "/bin/node", args: ["hi.js"], cwd: SCRATCH, debugAdapter: "js-debug" });
|
||
expect(p.config!.id).toBe(`context:file:${SCRATCH}/hi.js`);
|
||
});
|
||
|
||
it("TypeScript: node type stripping by version, then tsx / ts-node, else what is missing", () => {
|
||
expect(run(scratch("hi.ts"), env({ node: "23.6.0" }))).toMatchObject({ command: "/bin/node", args: ["hi.ts"] });
|
||
expect(run(scratch("hi.ts"), env({ node: "22.18.0" }))).toMatchObject({ args: ["hi.ts"] });
|
||
expect(run(scratch("hi.ts"), env({ node: "22.6.0" }))).toMatchObject({
|
||
args: ["--experimental-strip-types", "hi.ts"],
|
||
});
|
||
expect(run(scratch("hi.ts"), env({ node: "23.5.0" }))).toMatchObject({
|
||
args: ["--experimental-strip-types", "hi.ts"],
|
||
});
|
||
expect(run(scratch("hi.ts"), env({ node: "20.11.0", tsx: null }))).toMatchObject({
|
||
command: "/bin/tsx",
|
||
args: ["hi.ts"],
|
||
});
|
||
expect(run(scratch("hi.ts"), env({ node: "20.11.0", "ts-node": null }))).toMatchObject({ command: "/bin/ts-node" });
|
||
const missing = produceContext(scratch("hi.ts"), env({ node: "20.11.0" }))!;
|
||
expect(missing.name).toBe("hi.ts");
|
||
expect(missing.config).toBeUndefined();
|
||
expect(missing.missing!.body).toMatch(/22\.6\+.*Found Node\.js 20\.11\.0.*npm install -D tsx/);
|
||
expect(produceContext(scratch("hi.js"), env({}))!.missing!.body).toMatch(/Node\.js was not found.*nodejs\.org/);
|
||
});
|
||
|
||
it("type stripping versions", () => {
|
||
expect(nodeTypeStripping("22.5.1")).toBeNull();
|
||
expect(nodeTypeStripping("22.6.0")).toBe("flag");
|
||
expect(nodeTypeStripping("22.17.9")).toBe("flag");
|
||
expect(nodeTypeStripping("22.18.0")).toBe("native");
|
||
expect(nodeTypeStripping("23.5.0")).toBe("flag");
|
||
expect(nodeTypeStripping("23.6.0")).toBe("native");
|
||
expect(nodeTypeStripping("v?")).toBeNull();
|
||
});
|
||
|
||
it("Deno / Bun projects run JS and TS with their runtime", () => {
|
||
const input: ContextInput = { path: `${ROOT}/scripts/x.ts`, root: ROOT, configs: [] };
|
||
expect(run(input, { ...ALL, deno: true })).toMatchObject({
|
||
command: "/bin/deno",
|
||
args: ["run", "scripts/x.ts"],
|
||
cwd: null,
|
||
});
|
||
expect(produceContext(input, { ...ALL, deno: true })!.config!.debugAdapter).toBe("js-debug");
|
||
expect(run(input, { ...ALL, bun: true })).toMatchObject({ command: "/bin/bun", args: ["run", "scripts/x.ts"] });
|
||
// Bun's inspector is WebKit-only: js-debug can't attach.
|
||
expect(produceContext(input, { ...ALL, bun: true })!.config!.debugAdapter).toBeNull();
|
||
// Not for scratches outside the project.
|
||
expect(run(scratch("x.ts"), { ...ALL, deno: true })).toMatchObject({ command: "/bin/node" });
|
||
});
|
||
|
||
it("Python: the interpreter from settings, then the project's, then python3", () => {
|
||
expect(run(scratch("hi.py"), { ...ALL, python: "/work/app/.venv/bin/python" })).toMatchObject({
|
||
command: "/work/app/.venv/bin/python",
|
||
args: ["hi.py"],
|
||
cwd: SCRATCH,
|
||
});
|
||
const venv = [cfg({ id: "python:module:app", kind: "python", command: ".venv/bin/python", args: ["-m", "app"] })];
|
||
expect(run({ ...scratch("hi.py"), configs: venv })).toMatchObject({ command: "/work/app/.venv/bin/python" });
|
||
expect(run({ path: `${ROOT}/tools/gen.py`, root: ROOT, configs: venv })).toMatchObject({
|
||
command: ".venv/bin/python",
|
||
args: ["tools/gen.py"],
|
||
cwd: null,
|
||
});
|
||
expect(produceContext(scratch("hi.py"), ALL)!.config).toMatchObject({
|
||
command: "/bin/python3",
|
||
debugAdapter: "debugpy",
|
||
});
|
||
expect(produceContext(scratch("hi.py"), env({}))!.missing!.title).toBe("Cannot run 'hi.py'");
|
||
});
|
||
|
||
it("Python: a module with relative imports runs with -m from above its top package", () => {
|
||
const venv = [cfg({ id: "pytest", kind: "pytest", command: ".venv/bin/pytest" })];
|
||
const packages = new Set([`${ROOT}/src/tasks`, `${ROOT}/src/tasks/sub`]);
|
||
const at = (path: string, content: string) =>
|
||
run({ path, root: ROOT, configs: venv, content, pythonPackage: (d) => packages.has(d) });
|
||
const relative = "from .models import TaskList\n";
|
||
expect(at(`${ROOT}/src/tasks/cli.py`, relative)).toMatchObject({
|
||
command: `${ROOT}/.venv/bin/python`,
|
||
args: ["-m", "tasks.cli"],
|
||
cwd: "src",
|
||
});
|
||
expect(at(`${ROOT}/src/tasks/sub/__main__.py`, "from ..models import X\n")).toMatchObject({
|
||
args: ["-m", "tasks.sub"],
|
||
cwd: "src",
|
||
});
|
||
// A directory without `__init__.py` is still the module's package; a top-level one runs from the root.
|
||
expect(at(`${ROOT}/scripts/gen.py`, relative)).toMatchObject({ args: ["-m", "scripts.gen"], cwd: null });
|
||
// Without relative imports, the file runs as a script.
|
||
expect(at(`${ROOT}/src/tasks/cli.py`, "import argparse\n")).toMatchObject({
|
||
command: ".venv/bin/python",
|
||
args: ["src/tasks/cli.py"],
|
||
cwd: null,
|
||
});
|
||
});
|
||
|
||
it("shell scripts: the shell of the #! line, else by extension; extensionless files by #!", () => {
|
||
expect(run(scratch("a.sh", "#!/usr/bin/env bash\necho hi"))).toMatchObject({
|
||
command: "/bin/bash",
|
||
args: ["a.sh"],
|
||
});
|
||
expect(run(scratch("a.sh", "#!/bin/zsh -f\necho"))).toMatchObject({ command: "/bin/zsh", args: ["-f", "a.sh"] });
|
||
expect(run(scratch("a.sh", "echo"))).toMatchObject({ command: "/bin/sh" });
|
||
expect(run(scratch("a.zsh", "echo"))).toMatchObject({ command: "/bin/zsh" });
|
||
expect(run(scratch("tool", "#!/usr/bin/env ruby\nputs 1"))).toMatchObject({
|
||
name: "tool",
|
||
command: "/bin/ruby",
|
||
args: ["tool"],
|
||
});
|
||
expect(run(scratch("tool", "#!/usr/bin/env -S deno run -A\n"))).toMatchObject({
|
||
command: "/bin/deno",
|
||
args: ["run", "-A", "tool"],
|
||
});
|
||
expect(produceContext(scratch("tool", "#!/usr/bin/env -S deno run -A\n"), ALL)!.config!.debugAdapter).toBe(
|
||
"js-debug",
|
||
);
|
||
expect(run(scratch("tool", "#!/usr/bin/env python3\n"))).toMatchObject({ command: "/bin/python3", args: ["tool"] });
|
||
expect(run(scratch("build.bat"), { ...ALL, windows: true })).toMatchObject({
|
||
command: "cmd.exe",
|
||
args: ["/c", "build.bat"],
|
||
cwd: SCRATCH,
|
||
});
|
||
expect(run({ path: `${ROOT}/scripts/run.cmd`, root: ROOT, configs: [] }, { ...ALL, windows: true })).toMatchObject({
|
||
command: "cmd.exe",
|
||
args: ["/c", "run.cmd"],
|
||
cwd: "scripts",
|
||
});
|
||
expect(produceContext(scratch("build.bat"), ALL)).toBeNull();
|
||
expect(produceContext(scratch("notes", "just text"), ALL)).toBeNull();
|
||
expect(shebang("#!/usr/bin/env node")).toEqual(["node"]);
|
||
});
|
||
|
||
it("Ruby, PHP, Perl, Lua, R, Swift, Dart, Elixir, Scala CLI, Kotlin script", () => {
|
||
expect(run(scratch("a.rb"))).toMatchObject({ command: "/bin/ruby", args: ["a.rb"] });
|
||
expect(run(scratch("a.php"))).toMatchObject({ command: "/bin/php", args: ["a.php"] });
|
||
// Debuggable with php-debug, like a PHP file in PhpStorm.
|
||
expect(produceContext(scratch("a.php"), ALL)!.config!.debugAdapter).toBe("php-debug");
|
||
// The PHP chosen in Settings wins over the PATH one, also with no PHP on PATH (#1555).
|
||
const php81 = "/opt/homebrew/opt/[email protected]/bin/php";
|
||
expect(run(scratch("a.php"), { ...ALL, php: php81 })).toMatchObject({ command: php81, args: ["a.php"] });
|
||
expect(produceContext(scratch("a.php"), { ...ALL, php: php81 })!.config!.debugAdapter).toBe("php-debug");
|
||
expect(run(scratch("a.php"), env({}, { php: php81 }))).toMatchObject({ command: php81, args: ["a.php"] });
|
||
expect(run(scratch("tool", "#!/usr/bin/env php\n<?php echo 1;"), { ...ALL, php: php81 })).toMatchObject({
|
||
command: php81,
|
||
args: ["tool"],
|
||
});
|
||
expect(produceContext(scratch("a.php"), env({}))!.missing!.body).toMatch(/Settings › Languages & Frameworks › PHP/);
|
||
expect(run(scratch("a.pl"))).toMatchObject({ command: "/bin/perl" });
|
||
expect(run(scratch("a.lua"))).toMatchObject({ command: "/bin/lua" });
|
||
expect(run(scratch("a.R"))).toMatchObject({ command: "/bin/Rscript", args: ["a.R"] });
|
||
expect(run(scratch("a.swift"))).toMatchObject({ command: "/bin/swift", args: ["a.swift"] });
|
||
expect(run(scratch("a.dart"))).toMatchObject({ command: "/bin/dart", args: ["run", "a.dart"] });
|
||
expect(run(scratch("a.exs"))).toMatchObject({ command: "/bin/elixir", args: ["a.exs"] });
|
||
expect(run(scratch("a.sc"))).toMatchObject({ command: "/bin/scala-cli", args: ["run", "a.sc"] });
|
||
expect(run(scratch("a.kts"))).toMatchObject({ command: "/bin/kotlin", args: ["a.kts"] });
|
||
expect(run(scratch("a.kts"), env({ kotlinc: null }))).toMatchObject({
|
||
command: "/bin/kotlinc",
|
||
args: ["-script", "a.kts"],
|
||
});
|
||
expect(produceContext(scratch("a.rb"), env({}))!.missing!.body).toMatch(/brew install ruby/);
|
||
expect(produceContext(scratch("build.gradle.kts"), ALL)).toBeNull();
|
||
});
|
||
|
||
it("Go: go run <file> for package main outside a module", () => {
|
||
expect(run(scratch("hi.go", "package main\nfunc main() {}"))).toMatchObject({
|
||
command: "/bin/go",
|
||
args: ["run", "hi.go"],
|
||
cwd: SCRATCH,
|
||
});
|
||
expect(produceContext(scratch("lib.go", "package lib"), ALL)).toBeNull();
|
||
expect(produceContext(scratch("hi.go", "package main"), ALL)!.config!.debugAdapter).toBe("delve");
|
||
});
|
||
|
||
it("Go: the module's go run for packages in the project", () => {
|
||
const go = [cfg({ id: "go:run", kind: "go", command: "go", args: ["run", "."] })];
|
||
const p = produceContext(
|
||
{ path: `${ROOT}/cmd/tool/main.go`, root: ROOT, configs: go, content: "package main\nfunc main() {}" },
|
||
ALL,
|
||
)!;
|
||
expect(p.config).toMatchObject({ args: ["run", "./cmd/tool"] });
|
||
});
|
||
|
||
it("Go: nested modules win over previous single-file runs", () => {
|
||
const go = [
|
||
cfg({
|
||
id: "context:file:fixtures/go-app/cmd/server/main.go",
|
||
kind: "go",
|
||
args: ["run", "fixtures/go-app/cmd/server/main.go"],
|
||
}),
|
||
cfg({
|
||
id: "go:run:server:fixtures/go-app",
|
||
kind: "go",
|
||
args: ["run", "./cmd/server"],
|
||
cwd: "fixtures/go-app",
|
||
}),
|
||
];
|
||
const input = {
|
||
path: `${ROOT}/fixtures/go-app/cmd/server/main.go`,
|
||
root: ROOT,
|
||
configs: go,
|
||
content: "package main\nfunc main() {}",
|
||
};
|
||
for (const line of [undefined, 1]) {
|
||
expect(produceContext({ ...input, line }, ALL)?.config).toMatchObject({
|
||
id: "go:run:server:fixtures/go-app",
|
||
args: ["run", "./cmd/server"],
|
||
cwd: "fixtures/go-app",
|
||
});
|
||
}
|
||
});
|
||
|
||
it("Go: single-file runs never become module configurations", () => {
|
||
const input = {
|
||
path: `${ROOT}/standalone/main.go`,
|
||
root: ROOT,
|
||
configs: [] as RunConfig[],
|
||
content: "package main\nfunc main() {}",
|
||
};
|
||
const first = produceContext(input, ALL)!;
|
||
expect(first.config).toMatchObject({ id: "context:file:standalone/main.go", args: ["run", "standalone/main.go"] });
|
||
expect(produceContext({ ...input, configs: [first.config!] }, ALL)?.config).toEqual(first.config);
|
||
});
|
||
|
||
it("Go: typed modules own only their own files", () => {
|
||
const configs = [
|
||
...["api", "worker"].map((module) =>
|
||
cfg({
|
||
id: `go:run:${module}`,
|
||
kind: "go",
|
||
args: ["build", "-race", "."],
|
||
params: { module, goKind: "build", scope: "package", target: ".", toolArgs: "-race" },
|
||
}),
|
||
),
|
||
];
|
||
for (const module of ["api", "worker"]) {
|
||
expect(
|
||
produceContext(
|
||
{
|
||
path: `${ROOT}/${module}/cmd/server/main.go`,
|
||
root: ROOT,
|
||
configs,
|
||
content: "package main\nfunc main() {}",
|
||
},
|
||
ALL,
|
||
)?.config,
|
||
).toMatchObject({
|
||
args: ["build", "-race", "./cmd/server"],
|
||
params: { module, target: "./cmd/server", goKind: "build", runAfterBuild: "true" },
|
||
});
|
||
}
|
||
expect(
|
||
produceContext(
|
||
{ path: `${ROOT}/standalone/main.go`, root: ROOT, configs, content: "package main\nfunc main() {}" },
|
||
ALL,
|
||
)?.config,
|
||
).toMatchObject({ id: "context:file:standalone/main.go", args: ["run", "standalone/main.go"] });
|
||
});
|
||
|
||
it("Rust: rustc into the compile directory, then the binary; codelldb debugs the binary", () => {
|
||
const p = produceContext(scratch("hihi.rs", 'fn main() {\n println!("hi");\n}'), ALL)!;
|
||
expect(p.name).toBe("hihi.rs");
|
||
expect(p.config!.cwd).toBe(SCRATCH);
|
||
expect(p.config!.args).toEqual([]);
|
||
expect(p.config!.command).toMatch(
|
||
/^mkdir -p \/tmp\/openidea-run && \/bin\/rustc -g --edition 2021 -o \/tmp\/openidea-run\/hihi-\w+ hihi\.rs && \/tmp\/openidea-run\/hihi-\w+$/,
|
||
);
|
||
expect(p.config!.kind).toBe("rustc");
|
||
expect(p.debug!.build.command).toMatch(/rustc -g .* hihi\.rs$/);
|
||
expect(p.debug!.config).toMatchObject({ kind: "native", debugAdapter: "codelldb", args: [] });
|
||
expect(p.debug!.config.command).toMatch(/^\/tmp\/openidea-run\/hihi-\w+$/);
|
||
expect(produceContext(scratch("hihi.rs", "fn main() {}"), env({}))!.missing!.body).toMatch(/rustup/);
|
||
});
|
||
|
||
it("Rust: a test at the caret of a standalone file builds with --test and filters", () => {
|
||
const src = [
|
||
"fn main() {}",
|
||
"#[cfg(test)]",
|
||
"mod tests {",
|
||
" #[test]",
|
||
" fn adds() {",
|
||
" assert!(true);",
|
||
" }",
|
||
"}",
|
||
].join("\n");
|
||
const p = produceContext(scratch("calc.rs", src, 5), ALL)!;
|
||
expect(p).toMatchObject({ name: "tests::adds", scope: "element" });
|
||
expect(p.config!.command).toMatch(
|
||
/rustc -g --edition 2021 --test -o \S+-test calc\.rs && \S+-test tests::adds --exact$/,
|
||
);
|
||
expect(p.config!.isTest).toBe(true);
|
||
// On the module line: the whole module.
|
||
expect(produceContext(scratch("calc.rs", src, 2), ALL)!.config!.command).toMatch(/-test tests::$/);
|
||
// Outside: the file (main).
|
||
expect(produceContext(scratch("calc.rs", src, 0), ALL)!.name).toBe("calc.rs");
|
||
});
|
||
|
||
it("Rust in a Cargo project: cargo run --bin / --example, not rustc", () => {
|
||
const cargo = [
|
||
cfg({ id: "cargo:run:server", kind: "cargo", command: "cargo", args: ["run", "--bin", "server"] }),
|
||
cfg({ id: "cargo:test", kind: "cargo", command: "cargo", args: ["test"], isTest: true }),
|
||
];
|
||
const bin = produceContext(
|
||
{ path: `${ROOT}/src/bin/server.rs`, root: ROOT, configs: cargo, content: "fn main() {}" },
|
||
ALL,
|
||
)!;
|
||
expect(bin.config!.id).toBe("cargo:run:server");
|
||
const ex = produceContext(
|
||
{ path: `${ROOT}/examples/demo.rs`, root: ROOT, configs: cargo, content: "fn main() {}" },
|
||
ALL,
|
||
)!;
|
||
expect(ex.config).toMatchObject({ args: ["run", "--example", "demo"], name: "Run demo" });
|
||
expect(
|
||
produceContext({ path: `${ROOT}/src/lib.rs`, root: ROOT, configs: cargo, content: "pub fn x() {}" }, ALL),
|
||
).toBeNull();
|
||
const test = produceContext(
|
||
{ path: `${ROOT}/src/lib.rs`, root: ROOT, configs: cargo, content: "#[test]\nfn works() {\n}\n", line: 2 },
|
||
ALL,
|
||
)!;
|
||
expect(test.config).toMatchObject({ command: "cargo", args: ["test", "--", "works", "--exact"], name: "works" });
|
||
});
|
||
|
||
it("Java: java <File>.java outside a build project; the Application configuration inside", () => {
|
||
const src = "public class Hi {\n public static void main(String[] args) {\n System.out.println(1);\n }\n}";
|
||
expect(run(scratch("Hi.java", src))).toMatchObject({ command: "/bin/java", args: ["Hi.java"], cwd: SCRATCH });
|
||
expect(produceContext(scratch("Hi.java", src), ALL)!.config!.kind).toBe("java-file");
|
||
expect(produceContext(scratch("Lib.java", "class Lib {}"), ALL)).toBeNull();
|
||
const gradle = [cfg({ id: "gradle:test", kind: "gradle", command: "./gradlew", args: ["test"], isTest: true })];
|
||
const p = produceContext(
|
||
{
|
||
path: `${ROOT}/src/main/java/demo/Hi.java`,
|
||
root: ROOT,
|
||
configs: gradle,
|
||
content: `package demo;\n${src}`,
|
||
line: 3,
|
||
},
|
||
ALL,
|
||
)!;
|
||
expect(p.config).toMatchObject({ kind: "application", name: "Hi" });
|
||
expect(p.config!.args.at(-1)).toBe("demo.Hi");
|
||
});
|
||
|
||
it("Java: a main class after another top-level class compiles with javac, then runs with java -cp", () => {
|
||
const src =
|
||
"class Parent {\n void show() {}\n}\n\npublic class Test {\n" +
|
||
" public static void main(String[] args) {\n new Parent().show();\n }\n}\n";
|
||
const input = { path: `${ROOT}/src/Test.java`, root: ROOT, configs: [], content: src };
|
||
const out = "/tmp/openidea-run/Test-[a-z0-9]+-classes";
|
||
for (const p of [produceFile(input, ALL), produceContext(input, ALL), produceContext({ ...input, line: 6 }, ALL)]) {
|
||
expect(p!.config).toMatchObject({
|
||
id: "context:file:src/Test.java",
|
||
name: "Test.java",
|
||
kind: "java-file",
|
||
args: [],
|
||
cwd: null,
|
||
debugAdapter: "java-debug",
|
||
});
|
||
expect(p!.config!.command).toMatch(
|
||
new RegExp(`^mkdir -p (${out}) && /bin/javac -g -d \\1 src/Test\\.java && /bin/java -cp \\1 Test$`),
|
||
);
|
||
}
|
||
// The source launcher runs the first top-level class: a main there keeps `java Test.java`.
|
||
expect(
|
||
run(scratch("Test.java", "public class Test {\n public static void main(String[] a) {}\n}\nclass B {}")),
|
||
).toEqual({ name: "Test.java", command: "/bin/java", args: ["Test.java"], cwd: SCRATCH });
|
||
// Java 25 compact source file: no class, `java Hi.java` as before.
|
||
expect(run(scratch("Hi.java", "void main() {\n IO.println(1);\n}\nclass Helper {}\n"))).toMatchObject({
|
||
command: "/bin/java",
|
||
args: ["Hi.java"],
|
||
});
|
||
});
|
||
|
||
it("Java: the gutter runs the main class it marks, nested ones and those of a JDK on Windows too", () => {
|
||
const src = [
|
||
"package demo;",
|
||
"class First {",
|
||
" public static void main(String[] args) {}",
|
||
" static class Inner {",
|
||
" public static void main(String[] args) {}",
|
||
" }",
|
||
"}",
|
||
"public class Second {",
|
||
" public static void main(String[] args) {}",
|
||
"}",
|
||
].join("\n");
|
||
const at = (line?: number) => produceContext(scratch("Second.java", src, line), ALL)!;
|
||
// The file and the first class: the source launcher.
|
||
for (const p of [at(), at(2)]) expect(p.config).toMatchObject({ command: "/bin/java", args: ["Second.java"] });
|
||
const second = at(8);
|
||
expect(second).toMatchObject({ name: "Second", scope: "element" });
|
||
expect(second.config).toMatchObject({ id: `context:file:${SCRATCH}/Second.java#demo.Second`, name: "Second" });
|
||
expect(second.config!.command).toMatch(/ && \/bin\/java -cp \S+ demo\.Second$/);
|
||
expect(at(4).config!.command).toMatch(/ && \/bin\/java -cp \S+ 'demo\.First\$Inner'$/);
|
||
const jdk = "C:\\Program Files\\Java\\jdk-21\\bin\\java.exe";
|
||
const win: ToolEnv = {
|
||
tool: (n) => (n === "java" ? { path: jdk, version: null } : null),
|
||
compileDir: "C:/Users/me/AppData/Local/openidea/run",
|
||
windows: true,
|
||
};
|
||
const p = produceContext(scratch("Second.java", src, 8), win)!;
|
||
expect(p.config!.command).toMatch(
|
||
/^\(if not exist (C:\\Users\\me\\AppData\\Local\\openidea\\run\\Second-[a-z0-9]+-classes) mkdir \1\) && "C:\\Program Files\\Java\\jdk-21\\bin\\javac\.exe" -g -d \1 Second\.java && "C:\\Program Files\\Java\\jdk-21\\bin\\java\.exe" -cp \1 demo\.Second$/,
|
||
);
|
||
});
|
||
|
||
it("Java: a main in a Gradle module outside the project runs on the module's classpath", () => {
|
||
// `includeFlat 'billing'`: the module folder is a sibling of the project folder.
|
||
const svc = "/work/billing/modules/core";
|
||
const boot =
|
||
"package com.example;\nimport org.springframework.boot.SpringApplication;\n@SpringBootApplication\n" +
|
||
"public class ShopApplication {\n public static void main(String[] args) {\n" +
|
||
" SpringApplication.run(ShopApplication.class, args);\n }\n}";
|
||
const gradle = [cfg({ id: "gradle:test", kind: "gradle", command: "./gradlew", args: ["test"], isTest: true })];
|
||
const input = (contentRoots?: string[]) => ({
|
||
path: `${svc}/src/main/java/com/example/ShopApplication.java`,
|
||
root: ROOT,
|
||
configs: gradle,
|
||
content: boot,
|
||
line: 4,
|
||
contentRoots,
|
||
});
|
||
expect(produceContext(input(), ALL)!.config!.kind).toBe("java-file");
|
||
const p = produceContext(input(["/work/billing", svc]), ALL)!;
|
||
expect(p.config).toMatchObject({ kind: "spring-boot", module: svc, cwd: null });
|
||
expect(p.config!.args).toEqual(["-cp", "${classpath}", "com.example.ShopApplication"]);
|
||
});
|
||
|
||
it("Java: a class whose superclass declares main runs as a Spring Boot application", async () => {
|
||
const path = `${ROOT}/src/main/java/demo/AiChatApplication.java`;
|
||
const content = "package demo;\n\n@SpringBootApplication\npublic class AiChatApplication extends AtomStarter {}\n";
|
||
const gradle = [cfg({ id: "gradle:test", kind: "gradle", command: "./gradlew", args: ["test"], isTest: true })];
|
||
const input = { path, root: ROOT, configs: gradle, content, line: 3 };
|
||
expect(fileTargets(input, content)).toEqual([]);
|
||
await resolveCandidates(path, unresolvedCandidates(path, content), {
|
||
supertypes: async () => [{ ...superItem, name: "AtomStarter", path: "jdt://lib/AtomStarter.class" }],
|
||
text: async () => "package x; public class AtomStarter { public static void main(String[] a) {} }",
|
||
});
|
||
expect(fileTargets(input, content)).toMatchObject([
|
||
{ kind: "main", className: "demo.AiChatApplication", spring: true },
|
||
]);
|
||
expect(produceContext(input, ALL)!.config).toMatchObject({ kind: "spring-boot" });
|
||
expect(produceContext(input, ALL)!.config!.args.at(-1)).toBe("demo.AiChatApplication");
|
||
});
|
||
|
||
describe("Java / Kotlin mains in a build nested in a folder of repositories", () => {
|
||
// RuoYi-Vue-Plus (Maven) next to plus-ui (npm), opened together.
|
||
const boot =
|
||
"package org.dromara;\n\nimport org.springframework.boot.SpringApplication;\n\n@SpringBootApplication\n" +
|
||
"public class DromaraApplication {\n public static void main(String[] args) {\n" +
|
||
" SpringApplication.run(DromaraApplication.class, args);\n }\n}\n";
|
||
const nested = (kind: "maven" | "gradle", dir: string) => [
|
||
cfg({ id: "npm:plus-ui:dev", kind: "npm", command: "npm", args: ["run", "dev"], cwd: "plus-ui" }),
|
||
cfg({ id: `${kind}:test:${dir}`, kind, args: ["test"], cwd: dir, isTest: true }),
|
||
];
|
||
const at = (path: string, configs: RunConfig[], content = boot, line = 6): ContextInput => ({
|
||
path: `${ROOT}/${path}`,
|
||
root: ROOT,
|
||
configs,
|
||
content,
|
||
line,
|
||
});
|
||
const cases = [
|
||
{
|
||
kind: "maven",
|
||
dir: "RuoYi-Vue-Plus",
|
||
file: "RuoYi-Vue-Plus/ruoyi-admin/src/main/java/org/dromara/DromaraApplication.java",
|
||
},
|
||
{
|
||
kind: "gradle",
|
||
dir: "services/api",
|
||
file: "services/api/app/src/main/java/org/dromara/DromaraApplication.java",
|
||
},
|
||
] as const;
|
||
for (const { kind, dir, file } of cases) {
|
||
it(`${kind}: the Spring Boot application in its module, from the gutter and from the file`, () => {
|
||
const module = file.slice(0, file.indexOf("/src/"));
|
||
for (const p of [
|
||
produceContext(at(file, nested(kind, dir)), ALL),
|
||
produceFile(at(file, nested(kind, dir)), ALL),
|
||
]) {
|
||
expect(p!.config).toMatchObject({
|
||
id: `application:${module}:org.dromara.DromaraApplication`,
|
||
kind: "spring-boot",
|
||
name: "DromaraApplication",
|
||
command: "java",
|
||
module,
|
||
cwd: null,
|
||
});
|
||
expect(p!.config!.args).toEqual(["-cp", "${classpath}", "org.dromara.DromaraApplication"]);
|
||
}
|
||
});
|
||
}
|
||
|
||
it("a file outside every build still runs as a single source file", () => {
|
||
const src = "public class Hi {\n public static void main(String[] args) {}\n}\n";
|
||
const p = produceContext(at("scripts/Hi.java", nested("maven", "RuoYi-Vue-Plus"), src, 1), ALL)!;
|
||
expect(p.config).toMatchObject({ kind: "java-file", args: ["scripts/Hi.java"] });
|
||
});
|
||
|
||
it("Kotlin and test classes use the build that contains them", () => {
|
||
const kt = "package demo\n\nfun main() {\n println(1)\n}\n";
|
||
const p = produceContext(at("backend/app/src/main/kotlin/demo/App.kt", nested("gradle", "backend"), kt, 3), ALL)!;
|
||
expect(p.config).toMatchObject({ kind: "application", module: "backend/app", cwd: null });
|
||
expect(p.config!.args.at(-1)).toBe("demo.AppKt");
|
||
const test = "package demo;\nclass AppTest {\n @Test\n void works() {\n }\n}\n";
|
||
const t = produceContext(
|
||
at("backend/app/src/test/java/demo/AppTest.java", nested("gradle", "backend"), test, 4),
|
||
ALL,
|
||
)!;
|
||
expect(t.config).toMatchObject({ kind: "gradle", cwd: "backend" });
|
||
expect(t.config!.args).toEqual(["test", "--tests", "demo.AppTest.works"]);
|
||
});
|
||
});
|
||
|
||
it("C / C++: compile with debug info into the compile directory", () => {
|
||
const c = produceContext(scratch("a.c", "int main(void) { return 0; }"), ALL)!;
|
||
expect(c.config!.command).toMatch(
|
||
/^mkdir -p \/tmp\/openidea-run && \/bin\/clang -g -o \/tmp\/openidea-run\/a-\w+ a\.c && \/tmp\/openidea-run\/a-\w+$/,
|
||
);
|
||
const cpp = produceContext(scratch("a.cpp", "int main() {}"), env({ "g++": null }))!;
|
||
expect(cpp.config!.command).toMatch(/\/bin\/g\+\+ -g -std=c\+\+17 -o/);
|
||
expect(cpp.debug!.config.debugAdapter).toBe("lldb-dap");
|
||
expect(c.debug!.config.debugAdapter).toBe("lldb-dap");
|
||
expect(produceContext(scratch("lib.c", "int add(int a, int b);"), ALL)).toBeNull();
|
||
});
|
||
|
||
it("compiled binaries of same-named files in different directories don't collide", () => {
|
||
const a = produceContext({ ...scratch("x.rs", "fn main() {}"), path: "/a/x.rs" }, ALL)!;
|
||
const b = produceContext({ ...scratch("x.rs", "fn main() {}"), path: "/b/x.rs" }, ALL)!;
|
||
expect(a.debug!.config.command).not.toBe(b.debug!.config.command);
|
||
});
|
||
|
||
it("names come out without runtime lookups (menus)", () => {
|
||
expect(produceContext(scratch("hi.ts"), OPTIMISTIC)!.name).toBe("hi.ts");
|
||
expect(produceContext(scratch("hi.ts"), OPTIMISTIC)!.config!.command).toBe("npx");
|
||
expect(produceContext(scratch("hi.js"), OPTIMISTIC)!.config!.command).toBe("node");
|
||
});
|
||
});
|
||
|
||
describe("project producers", () => {
|
||
const configs = [
|
||
cfg({ id: "npm:test", kind: "npm", command: "npm", args: ["run", "test"], isTest: true }),
|
||
cfg({ id: "pytest", kind: "pytest", command: ".venv/bin/pytest", args: [], isTest: true }),
|
||
];
|
||
|
||
it("Godot scenes run with godot --path in the project holding them (Current Scene)", () => {
|
||
const godot = [
|
||
cfg({ id: "godot:project", kind: "godot", command: "godot", args: ["--path", "."], debugAdapter: "godot" }),
|
||
cfg({ id: "godot:project:demo", kind: "godot", command: "godot", args: ["--path", "."], cwd: "demo" }),
|
||
];
|
||
const root = produceContext({ path: `${ROOT}/levels/one.tscn`, root: ROOT, configs: godot }, OPTIMISTIC)!;
|
||
expect(root).toMatchObject({ name: "one.tscn", scope: "file" });
|
||
expect(root.config).toMatchObject({
|
||
kind: "godot",
|
||
command: "godot",
|
||
args: ["--path", ".", "res://levels/one.tscn"],
|
||
cwd: null,
|
||
debugAdapter: "godot",
|
||
});
|
||
const nested = produceContext({ path: `${ROOT}/demo/main.tscn`, root: ROOT, configs: godot }, OPTIMISTIC)!;
|
||
expect(nested.config).toMatchObject({ args: ["--path", ".", "res://main.tscn"], cwd: "demo" });
|
||
// Outside a Godot project there is nothing to run.
|
||
expect(produceContext({ path: `${ROOT}/a.tscn`, root: ROOT, configs: [] }, OPTIMISTIC)).toBeNull();
|
||
});
|
||
|
||
it("keeps package.json test files on the project's runner", () => {
|
||
const p = produceContext(
|
||
{ path: `${ROOT}/src/a.test.ts`, root: ROOT, configs, content: "test('x', () => {})" },
|
||
ALL,
|
||
)!;
|
||
expect(p).toMatchObject({ name: "a.test.ts", scope: "file" });
|
||
expect(p.config!.args).toEqual(["run", "test", "--", "src/a.test.ts"]);
|
||
});
|
||
|
||
it("⌃⇧R picks the innermost context: test > describe > file", () => {
|
||
const src = [
|
||
"describe('math', () => {",
|
||
" it('adds', () => {",
|
||
" expect(1).toBe(1);",
|
||
" });",
|
||
"",
|
||
"});",
|
||
"",
|
||
"const x = 1;",
|
||
].join("\n");
|
||
const at = (line: number) =>
|
||
produceContext({ path: `${ROOT}/src/m.test.ts`, root: ROOT, configs, content: src, line }, ALL)!;
|
||
expect(at(2)).toMatchObject({ name: "math > adds", scope: "element" });
|
||
expect(at(4)).toMatchObject({ name: "math", scope: "element" });
|
||
expect(at(7)).toMatchObject({ name: "m.test.ts", scope: "file" });
|
||
});
|
||
|
||
it("does not offer configurations produced by an earlier run as choices", () => {
|
||
const earlier = cfg({
|
||
id: "context:target:npm:test:src/m.test.ts:other",
|
||
name: "other",
|
||
kind: "npm",
|
||
command: "npm",
|
||
args: ["run", "test", "--", "src/m.test.ts"],
|
||
isTest: true,
|
||
});
|
||
const src = "describe('math', () => {\n it('adds', () => {\n });\n});\n";
|
||
const input = { path: `${ROOT}/src/m.test.ts`, root: ROOT, configs: [...configs, earlier], content: src, line: 1 };
|
||
expect(produceContextChoices(input, ALL).map((c) => c.name)).toEqual(["math > adds"]);
|
||
});
|
||
|
||
it("several equally close test configurations: one choice each, the default first", () => {
|
||
const withUnit = [
|
||
...configs,
|
||
cfg({
|
||
id: "npm:test:unit",
|
||
name: "npm run test:unit",
|
||
kind: "npm",
|
||
command: "npm",
|
||
args: ["run", "test:unit"],
|
||
isTest: true,
|
||
}),
|
||
];
|
||
const src = "describe('math', () => {\n it('adds', () => {\n });\n});\n";
|
||
const input = { path: `${ROOT}/src/m.test.ts`, root: ROOT, configs: withUnit, content: src, line: 1 };
|
||
const choices = produceContextChoices(input, ALL);
|
||
expect(choices.map((c) => [c.name, c.detail, c.scope])).toEqual([
|
||
["math > adds", "npm:test", "element"],
|
||
["math > adds", "npm run test:unit", "element"],
|
||
]);
|
||
expect(choices[1]!.config!.args.slice(0, 2)).toEqual(["run", "test:unit"]);
|
||
// The file context offers the same choice; a single matching configuration needs none.
|
||
expect(produceContextChoices({ ...input, line: undefined }, ALL).map((c) => c.detail)).toEqual([
|
||
"npm:test",
|
||
"npm run test:unit",
|
||
]);
|
||
expect(produceContextChoices({ ...input, configs }, ALL)).toHaveLength(1);
|
||
});
|
||
|
||
it("django: tests.py, TestCase classes and test packages run through manage.py test", () => {
|
||
const django = [
|
||
cfg({ id: "django:runserver", kind: "python", command: ".venv/bin/python", args: ["manage.py", "runserver"] }),
|
||
cfg({
|
||
id: "django:test",
|
||
kind: "django-test",
|
||
command: ".venv/bin/python",
|
||
args: ["manage.py", "test", "--keepdb"],
|
||
isTest: true,
|
||
}),
|
||
];
|
||
const src = [
|
||
"from django.test import TestCase",
|
||
"",
|
||
"class QuestionTests(TestCase):",
|
||
" def test_recent(self):",
|
||
" pass",
|
||
].join("\n");
|
||
const input = { path: `${ROOT}/polls/tests.py`, root: ROOT, configs: django, content: src };
|
||
expect(produceContext({ ...input, line: 4 }, ALL)).toMatchObject({
|
||
name: "QuestionTests.test_recent",
|
||
config: { kind: "django-test", args: ["manage.py", "test", "polls.tests.QuestionTests.test_recent", "--keepdb"] },
|
||
});
|
||
expect(produceContext({ ...input, line: 2 }, ALL)!.config!.args).toEqual([
|
||
"manage.py",
|
||
"test",
|
||
"polls.tests.QuestionTests",
|
||
"--keepdb",
|
||
]);
|
||
expect(produceContext(input, ALL)).toMatchObject({
|
||
name: "tests.py",
|
||
config: { args: ["manage.py", "test", "polls.tests", "--keepdb"] },
|
||
});
|
||
expect(produceDirectory({ path: `${ROOT}/polls/tests`, root: ROOT, configs: django })).toMatchObject({
|
||
name: "Tests in 'tests'",
|
||
config: { args: ["manage.py", "test", "polls.tests", "--keepdb"] },
|
||
});
|
||
expect(produceDirectory({ path: `${ROOT}/polls/migrations`, root: ROOT, configs: django })).toBeNull();
|
||
// Outside Django projects `tests.py` is a script: no test targets, and the file runs as one.
|
||
const plain = { ...input, configs: [configs[1]!] };
|
||
expect(fileTargets(plain, src)).toEqual([]);
|
||
expect(produceContext(plain, ALL)).toMatchObject({ name: "tests.py", config: { kind: "python", isTest: false } });
|
||
// pytest detected as well (pytest-django): the user chooses, pytest first.
|
||
const both = [configs[1]!, ...django];
|
||
expect(produceContextChoices({ ...input, configs: both, line: 4 }, ALL).map((c) => c.detail)).toEqual([
|
||
"pytest",
|
||
"django:test",
|
||
]);
|
||
});
|
||
|
||
it("python: test function > test class > file; __main__ runs the file", () => {
|
||
const src = [
|
||
"class TestMath:",
|
||
" def test_add(self):",
|
||
" assert 1",
|
||
"",
|
||
" x = 1",
|
||
"",
|
||
"def helper():",
|
||
" pass",
|
||
].join("\n");
|
||
const at = (line: number) =>
|
||
produceContext({ path: `${ROOT}/tests/test_m.py`, root: ROOT, configs, content: src, line }, ALL)!.name;
|
||
expect(at(2)).toBe("TestMath.test_add");
|
||
expect(at(4)).toBe("TestMath");
|
||
expect(at(7)).toBe("test_m.py");
|
||
const main = 'print(1)\nif __name__ == "__main__":\n main()\n';
|
||
expect(produceContext(scratch("hi.py", main, 2), ALL)!).toMatchObject({ name: "hi.py", scope: "element" });
|
||
});
|
||
|
||
it("directories: Tests in '<dir>' and All Tests", () => {
|
||
expect(produceDirectory({ path: `${ROOT}/tests`, root: ROOT, configs })).toMatchObject({
|
||
name: "Tests in 'tests'",
|
||
config: { args: ["tests"], command: ".venv/bin/pytest" },
|
||
});
|
||
expect(produceDirectory({ path: `${ROOT}/src/__tests__`, root: ROOT, configs })!.config!.args).toEqual([
|
||
"run",
|
||
"test",
|
||
"--",
|
||
"src/__tests__",
|
||
]);
|
||
const gradle = [cfg({ id: "gradle:test", kind: "gradle", command: "./gradlew", args: ["test"], isTest: true })];
|
||
expect(produceDirectory({ path: `${ROOT}/src/test/java`, root: ROOT, configs: gradle })).toMatchObject({
|
||
name: "All Tests",
|
||
});
|
||
expect(
|
||
produceDirectory({ path: `${ROOT}/src/test/java/com/acme`, root: ROOT, configs: gradle })!.config!.args,
|
||
).toEqual(["test", "--tests", "com.acme.*"]);
|
||
// A module's tests run from the build root so `./gradlew` resolves,
|
||
// qualified by the module's Gradle path.
|
||
const dir = produceDirectory({ path: `${ROOT}/services/api/src/test/java`, root: ROOT, configs: gradle })!;
|
||
expect(dir.config).toMatchObject({ cwd: null });
|
||
expect(dir.config!.args).toEqual([":services:api:test"]);
|
||
expect(
|
||
produceDirectory({ path: `${ROOT}/services/api/src/test/java/com/acme`, root: ROOT, configs: gradle })!.config!
|
||
.args,
|
||
).toEqual([":services:api:test", "--tests", "com.acme.*"]);
|
||
expect(produceDirectory({ path: `${ROOT}/src/main`, root: ROOT, configs })).toBeNull();
|
||
expect(produceDirectory({ path: ROOT, root: ROOT, configs })).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("file-level gutter targets", () => {
|
||
it("scratches: Python __main__, Go main, Rust fn main, C main; no JS", () => {
|
||
const at = (name: string, text: string) =>
|
||
fileTargets({ path: `${SCRATCH}/${name}`, root: ROOT, configs: [] }, text).map((t) => [t.line, t.kind]);
|
||
expect(at("hi.py", 'x = 1\nif __name__ == "__main__":\n print(x)')).toEqual([[1, "main"]]);
|
||
expect(at("hi.go", "package main\n\nfunc main() {\n}")).toEqual([[2, "main"]]);
|
||
expect(at("hihi.rs", "use std::io;\nfn main() {\n}")).toEqual([[1, "main"]]);
|
||
expect(at("a.c", "#include <stdio.h>\nint main(void) {\n}")).toEqual([[1, "main"]]);
|
||
expect(at("hi.js", "console.log(1)")).toEqual([]);
|
||
expect(at("hi.ts", "console.log(123)")).toEqual([]);
|
||
});
|
||
|
||
it("innermost target uses declaration extents", () => {
|
||
const src = "fn main() {\n}\n#[test]\nfn t() {\n x();\n}\n";
|
||
const targets = fileTargets({ path: `${SCRATCH}/a.rs`, root: ROOT, configs: [] }, src);
|
||
expect(innermostTarget(src, targets, 4)?.name).toBe("t");
|
||
expect(innermostTarget(src, targets, 2)).toBeNull();
|
||
expect(innermostTarget(src, targets, 0)?.kind).toBe("main");
|
||
});
|
||
});
|
||
|
||
describe("produceFile", () => {
|
||
it("ignores unknown files and declaration files", () => {
|
||
expect(produceFile(scratch("readme.md"), ALL)).toBeNull();
|
||
expect(produceFile(scratch("types.d.ts"), ALL)).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("Kotlin single file (.kt)", () => {
|
||
const KT = env({ kotlinc: null, java: null });
|
||
const hello = 'package demo\n\nfun main(args: Array<String>) {\n println("hi")\n}';
|
||
|
||
it("kotlinc into a runnable jar, then java -jar; no debugger", () => {
|
||
const p = produceContext(scratch("hello.kt", hello), KT)!;
|
||
expect(p.name).toBe("hello.kt");
|
||
expect(p.config).toMatchObject({ kind: "kotlinc", cwd: SCRATCH, args: [], debugAdapter: null });
|
||
expect(p.config!.command).toMatch(
|
||
/^mkdir -p \/tmp\/openidea-run && \/bin\/kotlinc -include-runtime -d \/tmp\/openidea-run\/hello-\w+\.jar hello\.kt && \/bin\/java -jar \/tmp\/openidea-run\/hello-\w+\.jar$/,
|
||
);
|
||
expect(p.debug).toBeUndefined();
|
||
// `suspend fun main()` and the gutter on it (scratch mains with a class name).
|
||
expect(produceContext(scratch("s.kt", "suspend fun main() {}"), KT)!.config!.kind).toBe("kotlinc");
|
||
const targets = fileTargets({ path: `${SCRATCH}/hello.kt`, root: ROOT, configs: [] }, hello);
|
||
expect(targets.map((t) => [t.line, t.kind])).toEqual([[2, "main"]]);
|
||
expect(produceContext(scratch("hello.kt", hello, 3), KT)!.config!.kind).toBe("kotlinc");
|
||
});
|
||
|
||
it("nothing to run without a top-level main; missing kotlinc / java are explained", () => {
|
||
expect(produceContext(scratch("lib.kt", "fun helper() = 42"), KT)).toBeNull();
|
||
const noKotlinc = produceContext(scratch("hello.kt", hello), env({ java: null }))!;
|
||
expect(noKotlinc.config).toBeUndefined();
|
||
expect(noKotlinc.missing!.title).toBe("Cannot run 'hello.kt'");
|
||
expect(noKotlinc.missing!.body).toMatch(
|
||
/The Kotlin compiler was not found \(looked for `kotlinc`\)\..*brew install kotlin/,
|
||
);
|
||
expect(produceContext(scratch("hello.kt", hello), env({ kotlinc: null }))!.missing!.body).toMatch(
|
||
/Java was not found.*JDK/,
|
||
);
|
||
});
|
||
|
||
it("a Gradle / Maven project runs .kt mains through the build tool", () => {
|
||
const gradle = [cfg({ id: "gradle:run", kind: "gradle", command: "./gradlew", args: ["run"] })];
|
||
const p = produceContext(
|
||
{ path: `${ROOT}/src/main/kotlin/App.kt`, root: ROOT, configs: gradle, content: hello },
|
||
KT,
|
||
)!;
|
||
expect(p.config!.kind).not.toBe("kotlinc");
|
||
});
|
||
});
|
||
|
||
describe("compile-and-run lines on Windows", () => {
|
||
const WIN = (tools: Record<string, string | null>) =>
|
||
({
|
||
tool: (n: string): LocatedTool | null =>
|
||
n in tools ? { path: `C:\\Program Files\\Tools\\${n}.exe`, version: tools[n] ?? null } : null,
|
||
compileDir: "C:\\Users\\me\\AppData\\Local\\Temp\\openidea-run",
|
||
windows: true,
|
||
}) satisfies ToolEnv;
|
||
const DIR = String.raw`C:\Users\me\AppData\Local\Temp\openidea-run`;
|
||
/** The command with the per-file hash replaced by `H`. */
|
||
const line = (p: { config?: { command: string } } | null) =>
|
||
p!.config!.command.replace(/-[0-9a-z]+(-test)?\.(exe|jar)/g, "-H$1.$2");
|
||
|
||
it("cmd quoting: double quotes only where needed, never POSIX single quotes", () => {
|
||
expect(shellWord(String.raw`C:\Program Files\x.exe`, true)).toBe(String.raw`"C:\Program Files\x.exe"`);
|
||
expect(shellWord(String.raw`C:\tools\x.exe`, true)).toBe(String.raw`C:\tools\x.exe`);
|
||
expect(shellWord("a&b", true)).toBe('"a&b"');
|
||
expect(shellWord("it's", true)).toBe(`"it's"`);
|
||
expect(shellWord("", true)).toBe('""');
|
||
expect(shellWord("it's")).toBe(String.raw`'it'\''s'`);
|
||
});
|
||
|
||
it("rustc: .exe output, backslash paths, cmd mkdir", () => {
|
||
const p = produceContext(scratch("hihi.rs", "fn main() {}"), WIN({ rustc: "1.85.0" }))!;
|
||
expect(line(p)).toBe(
|
||
`(if not exist ${DIR} mkdir ${DIR}) && "C:\\Program Files\\Tools\\rustc.exe" -g --edition 2021 -o ${DIR}\\hihi-H.exe hihi.rs && ${DIR}\\hihi-H.exe`,
|
||
);
|
||
expect(line(p.debug!)).toBe(`${DIR}\\hihi-H.exe`);
|
||
const test = produceContext(scratch("calc.rs", "#[test]\nfn adds() {}", 1), WIN({ rustc: null }))!;
|
||
expect(line(test)).toMatch(/--test -o \S+\\calc-H-test\.exe calc\.rs && \S+\\calc-H-test\.exe adds --exact$/);
|
||
});
|
||
|
||
it("clang / gcc and kotlinc", () => {
|
||
const c = produceContext(scratch("a.c", "int main(void) {}"), WIN({ gcc: null }))!;
|
||
expect(c.config!.command).toMatch(
|
||
/&& "C:\\Program Files\\Tools\\gcc\.exe" -g -o \S+\\a-\w+\.exe a\.c && \S+\\a-\w+\.exe$/,
|
||
);
|
||
const kt = produceContext(scratch("hello.kt", "fun main() {}"), WIN({ kotlinc: null, java: null }))!;
|
||
expect(kt.config!.command).toMatch(
|
||
/&& "C:\\Program Files\\Tools\\kotlinc\.exe" -include-runtime -d \S+\\hello-\w+\.jar hello\.kt && "C:\\Program Files\\Tools\\java\.exe" -jar \S+\\hello-\w+\.jar$/,
|
||
);
|
||
});
|
||
|
||
it("batch-file compilers (kotlinc.bat) are run with call", () => {
|
||
const e: ToolEnv = {
|
||
...WIN({}),
|
||
tool: (n) =>
|
||
({
|
||
kotlinc: { path: String.raw`C:\kotlin\bin\kotlinc.bat`, version: null },
|
||
java: { path: String.raw`C:\jdk\bin\java.exe`, version: null },
|
||
})[n] ?? null,
|
||
};
|
||
expect(line(produceContext(scratch("hello.kt", "fun main() {}"), e))).toBe(
|
||
`(if not exist ${DIR} mkdir ${DIR}) && call C:\\kotlin\\bin\\kotlinc.bat -include-runtime -d ${DIR}\\hello-H.jar hello.kt && C:\\jdk\\bin\\java.exe -jar ${DIR}\\hello-H.jar`,
|
||
);
|
||
});
|
||
|
||
it("a compile directory with spaces is quoted for cmd", () => {
|
||
const e = { ...WIN({ clang: null }), compileDir: "C:/Users/John Smith/Temp/openidea-run/" };
|
||
const p = produceContext(scratch("a.c", "int main() {}"), e)!;
|
||
expect(p.config!.command).toMatch(
|
||
/^\(if not exist "C:\\Users\\John Smith\\Temp\\openidea-run" mkdir "C:\\Users\\John Smith\\Temp\\openidea-run"\) && /,
|
||
);
|
||
expect(p.config!.command).toMatch(
|
||
/ -o "C:\\Users\\John Smith\\Temp\\openidea-run\\a-\w+\.exe" a\.c && "C:\\Users\\John Smith\\Temp\\openidea-run\\a-\w+\.exe"$/,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("Go test names (GoLand)", () => {
|
||
const GO = [
|
||
cfg({ id: "go:run", kind: "go", command: "go", args: ["run", "."], debugAdapter: "delve" }),
|
||
cfg({ id: "go:test", kind: "go", command: "go", args: ["test", "./..."], isTest: true, debugAdapter: "delve" }),
|
||
];
|
||
const src = [
|
||
"package calc",
|
||
"",
|
||
'import "testing"',
|
||
"",
|
||
"func TestAdd(t *testing.T) {",
|
||
"}",
|
||
"",
|
||
"func TestSub(t *testing.T) {",
|
||
'\tt.Run("zero case", func(t *testing.T) {',
|
||
"\t})",
|
||
"}",
|
||
"",
|
||
"func TestTable(t *testing.T) {",
|
||
"\ttests := []struct{ name string }{",
|
||
'\t\t{name: "one plus one"},',
|
||
"\t}",
|
||
"\tfor _, tt := range tests {",
|
||
"\t\tt.Run(tt.name, func(t *testing.T) {})",
|
||
"\t}",
|
||
"}",
|
||
].join("\n");
|
||
const modules = (dir: string) => (dir === ROOT ? "example.com/fixture" : undefined);
|
||
const at = (line: number | undefined, goModule?: ContextInput["goModule"]) =>
|
||
produceContext({ path: `${ROOT}/calc/calc_test.go`, root: ROOT, configs: GO, content: src, line, goModule }, ALL)!;
|
||
|
||
it("a test function: 'TestFoo in <import path>' with -run '^TestFoo$'", () => {
|
||
const p = at(5, modules);
|
||
expect(p.name).toBe("TestAdd in example.com/fixture/calc");
|
||
expect(p.config).toMatchObject({
|
||
args: ["test", "./calc", "-run", "^TestAdd$"],
|
||
debugAdapter: "delve",
|
||
isTest: true,
|
||
});
|
||
});
|
||
|
||
it("subtests and table-driven entries", () => {
|
||
expect(at(8, modules)).toMatchObject({ name: "TestSub/zero_case in example.com/fixture/calc" });
|
||
expect(at(8, modules).config!.args).toEqual(["test", "./calc", "-run", "^TestSub$/^zero_case$"]);
|
||
expect(at(14, modules).config!.args).toEqual(["test", "./calc", "-run", "^TestTable$/^one_plus_one$"]);
|
||
// Inside the t.Run body of a table test: the whole test.
|
||
expect(at(17, modules).config!.args).toEqual(["test", "./calc", "-run", "^TestTable$"]);
|
||
});
|
||
|
||
it("the file and the directory: 'go test <import path>'", () => {
|
||
expect(at(undefined, modules).name).toBe("go test example.com/fixture/calc");
|
||
expect(at(undefined, modules).config!.args).toEqual(["test", "./calc"]);
|
||
const dir = produceDirectory({ path: `${ROOT}/internal/tests`, root: ROOT, configs: GO, goModule: modules })!;
|
||
expect(dir.name).toBe("go test example.com/fixture/internal/tests/...");
|
||
expect(dir.config!.args).toEqual(["test", "./internal/tests/..."]);
|
||
expect(dir.config!.name).toBe(dir.name);
|
||
});
|
||
|
||
it("without go.mod read yet: the package clause", () => {
|
||
expect(at(5).name).toBe("TestAdd in calc");
|
||
expect(at(undefined).name).toBe("go test calc");
|
||
});
|
||
|
||
it("nested modules use their own go.mod", () => {
|
||
const nested = [
|
||
cfg({ id: "go:test:api", kind: "go", command: "go", args: ["test", "./..."], cwd: "api", isTest: true }),
|
||
];
|
||
const p = produceContext(
|
||
{
|
||
path: `${ROOT}/api/calc_test.go`,
|
||
root: ROOT,
|
||
configs: nested,
|
||
content: src,
|
||
line: 5,
|
||
goModule: (d) => (d === `${ROOT}/api` ? "example.com/api" : undefined),
|
||
},
|
||
ALL,
|
||
)!;
|
||
expect(p.name).toBe("TestAdd in example.com/api");
|
||
expect(p.config!.args).toEqual(["test", ".", "-run", "^TestAdd$"]);
|
||
});
|
||
});
|
||
|
||
describe("CMake file context", () => {
|
||
const configure = cfg({
|
||
id: "cmake:build:configure",
|
||
kind: "cmake",
|
||
command: "cmake",
|
||
args: ["-S", ".", "-B", "build"],
|
||
});
|
||
const native = cfg({
|
||
id: "cmake:build:Debug:app",
|
||
name: "app",
|
||
kind: "native",
|
||
command: "/p/build/app",
|
||
debugAdapter: "lldb-dap",
|
||
params: { cmakeSources: "main.cpp\nmath.cpp" },
|
||
beforeLaunch: [{ kind: "run", target: "cmake:build:Debug:app:build" }],
|
||
});
|
||
const input = { path: "/p/main.cpp", root: "/p", configs: [configure, native], content: "int main() {}", line: 0 };
|
||
it("runs the owning executable and keeps its build task", () => {
|
||
expect(produceContext(input, OPTIMISTIC)?.config).toEqual(native);
|
||
});
|
||
it("matches absolute source paths from a linked workspace member", () => {
|
||
const target = { ...native, params: { cmakeSources: "/workspace/native/main.cpp" } };
|
||
const p = produceContext(
|
||
{ ...input, path: "/workspace/native/main.cpp", root: "/workspace", configs: [configure, target] },
|
||
OPTIMISTIC,
|
||
);
|
||
expect(p?.config).toEqual(target);
|
||
});
|
||
it("offers all executable targets sharing the source", () => {
|
||
const second = { ...native, id: "other", name: "other" };
|
||
expect(
|
||
produceContextChoices({ ...input, configs: [configure, native, second] }, OPTIMISTIC).map((p) => p.config?.id),
|
||
).toEqual([native.id, second.id]);
|
||
});
|
||
it("never compiles a CMake source as a standalone file when the target is unknown", () => {
|
||
const p = produceContext({ ...input, configs: [configure] }, OPTIMISTIC);
|
||
expect(p?.config).toBeUndefined();
|
||
expect(p?.missing?.body).toContain("CMake Configure");
|
||
});
|
||
});
|
||
|
||
describe("module ownership across languages", () => {
|
||
it.each([
|
||
["npm", "other.test.ts", ["run", "test"]],
|
||
["deno", "other.test.ts", ["test"]],
|
||
["cargo", "tests/other.rs", ["test"]],
|
||
["go", "other_test.go", ["test", "./..."]],
|
||
["phpunit", "OtherTest.php", []],
|
||
["rspec", "other_spec.rb", []],
|
||
["maven", "OtherTest.java", ["test"]],
|
||
["gradle", "OtherTest.kt", ["test"]],
|
||
])("%s never borrows a sibling test configuration", (kind, file, args) => {
|
||
const configs = [cfg({ id: "sibling", kind, args, cwd: "apps/one", isTest: true })];
|
||
const result = produceFile({ root: ROOT, path: `${ROOT}/apps/two/${file}`, configs }, OPTIMISTIC);
|
||
expect(result?.config?.id ?? "").not.toContain("sibling");
|
||
});
|
||
|
||
it("selects the nearest Python interpreter and ignores temporary configs", () => {
|
||
const configs = [
|
||
cfg({
|
||
id: "one",
|
||
kind: "pytest",
|
||
command: "/env/one/bin/pytest",
|
||
cwd: "one",
|
||
isTest: true,
|
||
}),
|
||
cfg({
|
||
id: "two",
|
||
kind: "pytest",
|
||
command: "/env/two/bin/pytest",
|
||
cwd: "two",
|
||
isTest: true,
|
||
}),
|
||
cfg({
|
||
id: "context:old",
|
||
kind: "python",
|
||
command: "/env/stale/python",
|
||
cwd: "two",
|
||
}),
|
||
];
|
||
expect(produceFile({ root: ROOT, path: `${ROOT}/two/main.py`, configs }, OPTIMISTIC)?.config?.command).toBe(
|
||
"/env/two/bin/python",
|
||
);
|
||
});
|
||
|
||
it("a Cargo package owns its binary even when commands run at the workspace root", () => {
|
||
const configs = [
|
||
cfg({
|
||
id: "cargo:run:one",
|
||
kind: "cargo",
|
||
module: "crates/one",
|
||
args: ["run", "-p", "one", "--bin", "server"],
|
||
}),
|
||
cfg({
|
||
id: "workspace:member:cargo:run:two",
|
||
kind: "cargo",
|
||
module: "crates/two",
|
||
args: ["run", "-p", "two", "--bin", "server"],
|
||
}),
|
||
];
|
||
const result = produceFile({ root: ROOT, path: `${ROOT}/crates/two/src/bin/server.rs`, configs }, OPTIMISTIC);
|
||
expect(result?.config?.args).toEqual(configs[1]!.args);
|
||
expect(result?.config?.cwd).toBeNull();
|
||
});
|
||
|
||
it.each(["/work/app", "C:/work/app"])("matches absolute workspace configs under %s", (root) => {
|
||
const configs = [
|
||
cfg({
|
||
id: "test",
|
||
kind: "npm",
|
||
cwd: `${root}/apps/web`,
|
||
module: `${root}/apps/web`,
|
||
args: ["run", "test"],
|
||
isTest: true,
|
||
}),
|
||
];
|
||
const result = produceFile({ root, path: `${root}/apps/web/a.test.ts`, configs }, OPTIMISTIC);
|
||
expect(result?.config).toMatchObject({
|
||
cwd: "apps/web",
|
||
args: ["run", "test", "--", "a.test.ts"],
|
||
});
|
||
});
|
||
});
|
||
|
||
it("runs scripts from their owning module with arguments relative to that directory", () => {
|
||
const configs = [cfg({ id: "web", kind: "npm", cwd: "apps/web", module: "apps/web" })];
|
||
expect(produceFile({ root: ROOT, path: `${ROOT}/apps/web/src/main.js`, configs }, OPTIMISTIC)?.config).toMatchObject({
|
||
cwd: "apps/web",
|
||
args: ["src/main.js"],
|
||
});
|
||
});
|
||
|
||
it("nested Ruby scripts use their own source paths relative to their cwd", () => {
|
||
const spy = vi.spyOn(projectRoots, "currentRoots").mockReturnValue([
|
||
{ path: "apps/ruby/lib", kind: "source", detectedFrom: null },
|
||
{ path: "apps/other/lib", kind: "source", detectedFrom: null },
|
||
]);
|
||
try {
|
||
const configs = [cfg({ id: "ruby", kind: "rake", cwd: "apps/ruby", args: ["test"] })];
|
||
expect(
|
||
produceFile({ root: ROOT, path: `${ROOT}/apps/ruby/bin/main.rb`, configs }, OPTIMISTIC)?.config,
|
||
).toMatchObject({ cwd: "apps/ruby", args: ["-Ilib", "bin/main.rb"] });
|
||
} finally {
|
||
spy.mockRestore();
|
||
}
|
||
});
|