Файли
openidea/src/features/run/context.ts
T

417 рядки
17 KiB
TypeScript
Неформатований Анотація Історія

Цей файл містить неоднозначні символи Юнікоду
Цей файл містить символи Юнікоду, які можна сплутати з іншими символами. Якщо ви вважаєте, що це зроблено навмисно, можете сміливо ігнорувати це попередження. Щоб показати їх, скористайтеся кнопкою Escape.
/**
* "Run Context Configuration" (⌃⇧R): derives a run configuration from the
* active file — the file's tests for test files, the matching run target or a
* direct interpreter invocation for main files. Best effort, IDEA-style.
*/
import type { RunConfig } from "@/platform/ipc";
import { djangoLabel, djangoTestsOf, hasDjangoTests } from "./djangoTypes";
import { phpunitArgs } from "./testing/phpunit";
import { goTestParts } from "./goArguments";
import { goBuildType } from "./goTypes";
import { isWindows } from "./jvmTypes";
import { typeFor } from "./types";
import { runTemplate } from "./runTemplates";
import { appendScriptArgs } from "./scriptArgs";
export type FileRole = "test" | "main" | "script" | null;
/** `django`: a Django project, whose default test module is `tests.py` (elsewhere a script). */
export function fileRole(relPath: string, content?: string, windows = isWindows(), django = false): FileRole {
const name = relPath.split("/").pop() ?? relPath;
if (
/\.(test|spec)\.[cm]?[jt]sx?$/.test(name) ||
/(^|\/)__tests__\//.test(relPath) ||
/^test_.*\.py$|_test\.py$/.test(name) ||
(django && name === "tests.py") ||
/_test\.go$/.test(name) ||
/Test\.php$/.test(name) ||
/_(spec|test)\.rb$|^test_.*\.rb$/.test(name) ||
/(Test|Tests|Spec|IT)\.(kt|java|scala|groovy)$/.test(name) ||
(/(^|\/)tests\/[^/]+\.rs$/.test(relPath) && name !== "mod.rs")
)
return "test";
if (/(^|\/)src\/main\.rs$|(^|\/)src\/bin\/[^/]+\.rs$/.test(relPath)) return "main";
if (/\.go$/.test(name) && content !== undefined && /^\s*package\s+main\b/m.test(content)) return "main";
if (/\.(kt|java)$/.test(name) && content !== undefined && /\bfun\s+main\s*\(|static\s+void\s+main\s*\(/.test(content))
return "main";
if (/\.(py|rb|sh|bash|zsh|[cm]?js|ts)$/.test(name)) return "script";
if (windows && /\.(bat|cmd)$/i.test(name)) return "script";
return null;
}
const dirOf = (p: string) => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "");
const stem = (p: string) => (p.split("/").pop() ?? p).replace(/\.[^.]+$/, "");
/** Kinds whose test configs can run a file of this extension. */
function testKinds(relPath: string): string[] {
if (/\.[cm]?[jt]sx?$/.test(relPath)) return ["npm", "deno", "bun"];
if (/\.py$/.test(relPath)) return ["pytest", "python", "django-test"];
if (/\.php$/.test(relPath)) return ["phpunit"];
if (/_spec\.rb$/.test(relPath)) return ["rspec", "rake", "ruby"];
if (/(^|\/)test_[^/]*\.rb$|_test\.rb$/.test(relPath)) return ["rake", "rspec", "ruby"];
if (/\.go$/.test(relPath)) return ["go"];
if (/\.rs$/.test(relPath)) return ["cargo"];
if (/\.(kt|java|scala|groovy)$/.test(relPath)) return ["gradle", "maven"];
return [];
}
/** The module owning a configuration's targets, relative to the project root. */
export function configScope(config: RunConfig): string {
return (
config.kind === "go"
? (config.params?.module ?? config.module ?? config.cwd ?? "")
: (config.module ?? config.cwd ?? "")
)
.replace(/^\.(?:\/|$)/, "")
.replace(/\/+$/, "");
}
/** Configs whose working directory most specifically contains `relPath` (ties in their original order). */
export function allClosest(configs: readonly RunConfig[], relPath: string): RunConfig[] {
const score = (c: RunConfig) => {
const cwd = configScope(c);
if (!cwd) return 0;
return relPath === cwd || relPath.startsWith(cwd + "/") ? cwd.length : -1;
};
const scored = configs
.filter((c) => !c.id.startsWith("context:"))
.map((c) => [c, score(c)] as const)
.filter(([, s]) => s >= 0);
const best = Math.max(...scored.map(([, s]) => s));
return scored.filter(([, s]) => s === best).map(([c]) => c);
}
/** Config whose working directory most specifically contains `relPath`. */
function closest(configs: RunConfig[], relPath: string): RunConfig | undefined {
return allClosest(configs, relPath)[0];
}
/** `relPath` relative to the working directory of `config`. */
export function relToCwd(relPath: string, config: RunConfig): string {
const cwd = (config.kind === "go" ? configScope(config) : (config.cwd ?? ""))
.replace(/^\.(?:\/|$)/, "")
.replace(/\/+$/, "");
return relPath === cwd ? "" : cwd && relPath.startsWith(cwd + "/") ? relPath.slice(cwd.length + 1) : relPath;
}
function derived(base: RunConfig, relPath: string, patch: Partial<RunConfig>): RunConfig {
const name = relPath.split("/").pop() ?? relPath;
return {
...base,
id: `context:${base.id}:${relPath}`,
name,
detected: true,
env: { ...base.env },
...patch,
};
}
/**
* The project's Python interpreter as `(command, leading args)`, derived from
* detected configs (venv / uv / poetry), or null when none says.
*/
export function projectPython(configs: RunConfig[], relPath?: string): { command: string; prefix: string[] } | null {
if (relPath !== undefined)
configs = allClosest(
configs.filter((c) => ["python", "pytest", "django-test"].includes(c.kind)),
relPath,
);
const py = configs.find((c) => c.kind === "python");
if (py) {
// The interpreter part ends where the program starts: `-m`, or the script (`manage.py`). What follows
// (`runserver`, `test`…) belongs to that program, never to the interpreter.
const end = py.args.findIndex((a) => a === "-m" || a.endsWith(".py"));
const python = py.args.findIndex((a) => /^python[\d.]*$/.test(a));
return {
command: py.command,
prefix: py.args.slice(0, end >= 0 ? end : python + 1),
};
}
const pytest = configs.find((c) => c.kind === "pytest");
if (!pytest) return null;
const venvTool = /^(.*[/\\](?:bin|Scripts)[/\\])pytest(\.exe)?$/.exec(pytest.command);
if (venvTool) return { command: `${venvTool[1]}python${venvTool[2] ?? ""}`, prefix: [] };
const m = pytest.args.indexOf("-m");
if (m >= 0 && pytest.args[m + 1] === "pytest") return { command: pytest.command, prefix: pytest.args.slice(0, m) };
if (pytest.args[0] === "run" && pytest.args[1] === "pytest")
return { command: pytest.command, prefix: ["run", "python"] };
return null;
}
/** `python` on Windows (where `python3` is not a command), `python3` elsewhere. */
function systemPython(): string {
return isWindows() ? "python" : "python3";
}
/**
* PyCharm runs pytest for a `test_*.py` file even when the project declares no
* test setup: `python -m pytest`, with the project's interpreter when detected.
* The "Edit configuration templates…" › pytest values win over both the
* interpreter and the runner arguments, like a template a new config inherits.
*/
function implicitPytest(relPath: string, configs: RunConfig[]): RunConfig | null {
if (!relPath.endsWith(".py")) return null;
const owners = allClosest(
configs.filter((c) => ["python", "pytest", "django-test"].includes(c.kind)),
relPath,
);
const py = projectPython(owners);
const base: RunConfig = {
id: "pytest:implicit",
name: "pytest",
kind: "pytest",
command: py?.command ?? systemPython(),
args: [...(py?.prefix ?? []), "-m", "pytest"],
cwd: owners[0]?.cwd ?? null,
module: owners[0]?.module,
env: { ...owners[0]?.env },
detected: true,
isTest: true,
debugAdapter: "debugpy",
};
return applyPytestTemplate(base);
}
/**
* "Edit configuration templates…" › pytest applied to a gutter/context pytest
* configuration: its Command and Runner arguments replace the detected
* interpreter (`pixi` + `run pytest`, a venv's `pytest`, …) while the file the
* context adds stays the target — a target stored on the template itself is
* dropped, or the run would execute both it and the file. Without a template
* the config is unchanged.
*/
function applyPytestTemplate(config: RunConfig): RunConfig {
const type = typeFor("pytest");
const template = runTemplate("pytest");
const params = type && template ? { ...type.defaults, ...template.params, target: "" } : null;
if (!type || !params) return config;
const line = type.commandLine(params);
if (!line) return config;
return { ...config, command: line.command, args: line.args };
}
/**
* Test configurations that can run `relPath` equally well (same, closest working
* directory: `npm run test` and `npm run test:unit`…); the default one first.
*/
export function testBasesFor(relPath: string, configs: RunConfig[]): RunConfig[] {
const kinds = testKinds(relPath);
// Configurations produced from a test or file are results of narrowing, never bases for it.
const candidates = configs
.filter((c) => c.isTest && kinds.includes(c.kind) && !c.id.startsWith("context:"))
.sort((a, b) => kinds.indexOf(a.kind) - kinds.indexOf(b.kind));
const best = allClosest(candidates, relPath);
if (best.length) return best;
const fallback = implicitPytest(relPath, configs);
return fallback ? [fallback] : [];
}
/**
* File-level test configuration for `relPath` (the closest matching test config,
* or `base`, narrowed to the file).
*/
export function testConfigFor(relPath: string, configs: RunConfig[], base?: RunConfig): RunConfig | null {
base ??= testBasesFor(relPath, configs)[0];
if (!base) return null;
const file = relToCwd(relPath, base);
switch (base.kind) {
case "npm":
case "bun": {
const args = appendScriptArgs(base.command, base.args, [file]);
return derived(base, relPath, { args });
}
case "deno":
return derived(base, relPath, { args: [...base.args, file] });
case "pytest":
case "python":
case "rspec":
return derived(base, relPath, { args: [...base.args, file] });
case "phpunit":
return derived(base, relPath, { args: phpunitArgs(base.args, { target: file }) });
case "rake": {
// `rake test` (minitest) takes the file as TEST=; `rake spec` as SPEC=.
const task = base.args.at(-1) ?? "";
const key = /(^|:)spec$/.test(task) ? "SPEC" : /(^|:)test$/.test(task) ? "TEST" : null;
return key ? derived(base, relPath, { args: [...base.args, `${key}=${file}`] }) : base;
}
case "ruby":
// The detected Minitest runner (`ruby … -e <runner> test`): its last argument is what to load.
return derived(base, relPath, { args: [...base.args.slice(0, -1), file] });
case "django-test": {
const tests = djangoTestsOf(base, [djangoLabel(file)]);
return tests ? derived(base, relPath, tests) : null;
}
case "go": {
const dir = dirOf(file);
const target = dir ? `./${dir}` : ".";
const parts = goTestParts(base.args);
const args = ["test", ...parts.flags, target, ...parts.program];
return derived(base, relPath, {
args,
name: `go test ${target}`,
...(base.params?.goKind ? { params: { ...base.params, scope: "directory", target } } : {}),
});
}
case "cargo": {
const m = /(?:^|\/)tests\/([^/]+)\.rs$/.exec(relPath);
return m ? derived(base, relPath, { args: [...base.args, "--test", m[1]!] }) : base;
}
case "gradle":
return derived(base, relPath, { args: [...base.args, "--tests", `*${stem(relPath)}`] });
case "maven":
return derived(base, relPath, { args: [...base.args, `-Dtest=${stem(relPath)}`] });
}
return base;
}
function mainConfigFor(relPath: string, configs: RunConfig[]): RunConfig | null {
if (relPath.endsWith(".rs")) {
const bin = /src\/bin\/([^/]+)\.rs$/.exec(relPath)?.[1];
const runs = allClosest(
configs.filter((c) => c.kind === "cargo" && c.args[0] === "run"),
relPath,
);
const byBin = bin ? runs.find((c) => c.id === `cargo:run:${bin}` || c.args.includes(bin)) : undefined;
return byBin ?? runs[0] ?? null;
}
if (relPath.endsWith(".go")) {
const gos = allClosest(
configs.filter((c) => c.kind === "go" && !c.id.startsWith("context:")),
relPath,
);
const runs = gos.filter((c) => c.args[0] === "run" || c.params?.goKind === "build");
// The module containing the file (nested modules run from their own directory).
const base = runs[0] ?? gos[0];
if (!base) return null;
const dir = dirOf(relToCwd(relPath, base));
const target = dir ? `./${dir}` : ".";
if (base.params?.goKind === "build") {
const params = {
...base.params,
goKind: "build",
scope: "package",
target,
runAfterBuild: "true",
};
return derived(base, relPath, {
...goBuildType.commandLine(params),
params,
name: `go run ${target}`,
isTest: false,
});
}
const hit = runs.find((c) => configScope(c) === configScope(base) && c.args[1] === target);
if (hit) return hit;
return derived(base, relPath, {
args: ["run", target],
name: `go run ${target}`,
isTest: false,
...(base.params?.goKind === "test" ? { params: undefined, cwd: base.params.module ?? base.cwd } : {}),
});
}
if (/\.(kt|java)$/.test(relPath)) {
// The build containing the file (builds nested in a folder of repositories carry a `:<dir>` suffix).
const runs = configs.filter((c) =>
["gradle:bootRun", "gradle:run", "maven:spring-boot:run"].some((id) => c.id === id || c.id.startsWith(`${id}:`)),
);
return closest(runs, relPath) ?? null;
}
return null;
}
/** `from .models import …`, `from . import x`: the file is a module of a package. */
const RELATIVE_IMPORT = /^[ \t]*from[ \t]+\.+[\w.]*[ \t]+import\b/m;
const IDENTIFIER = /^[A-Za-z_]\w*$/;
/**
* A module that imports relatively runs as `python -m <package>.<module>` from the folder its top
* package sits in (the last `src` of its path, else the Python project's folder): run as a script,
* its relative imports fail. `null` for other files.
*/
export function pythonModuleRun(
relPath: string,
content: string | undefined,
configs: RunConfig[],
): { cwd: string; module: string } | null {
if (!content || !RELATIVE_IMPORT.test(content)) return null;
const project = closest(
configs.filter((c) => c.kind === "python" || c.kind === "pytest"),
relPath,
)?.cwd?.replace(/^\.\/?|\/+$/g, "");
const segs = relPath.replace(/\.py$/, "").split("/");
const first = project && relPath.startsWith(`${project}/`) ? project.split("/").length : 0;
const src = segs.lastIndexOf("src", segs.length - 2);
const start = src >= first ? src + 1 : first;
const parts = segs.slice(start);
// Only a module inside a package can import relatively.
if (parts.length < 2 || !parts.every((p) => IDENTIFIER.test(p))) return null;
if (parts.at(-1) === "__main__" || parts.at(-1) === "__init__") parts.pop();
return { cwd: segs.slice(0, start).join("/"), module: parts.join(".") };
}
/** A command relative to the project root (`.venv/bin/python`), from `cwd` inside it. */
function fromSubfolder(command: string, cwd: string): string {
if (!cwd || !command.includes("/") || command.startsWith("/") || /^[A-Za-z]:/.test(command)) return command;
return "../".repeat(cwd.split("/").length) + command;
}
function scriptConfigFor(
relPath: string,
configs: RunConfig[],
windows = isWindows(),
content?: string,
): RunConfig | null {
const base: RunConfig = {
id: `context:file:${relPath}`,
name: relPath.split("/").pop() ?? relPath,
kind: "shell",
command: "",
args: [],
cwd: null,
env: {},
detected: true,
isTest: false,
debugAdapter: null,
};
if (relPath.endsWith(".py")) {
// Reuse the project's interpreter (venv / uv / poetry) when detected.
const py = projectPython(configs) ?? { command: systemPython(), prefix: [] };
const module = pythonModuleRun(relPath, content, configs);
if (module)
return {
...base,
kind: "python",
command: fromSubfolder(py.command, module.cwd),
args: [...py.prefix, "-m", module.module],
cwd: module.cwd || null,
debugAdapter: "debugpy",
};
return { ...base, kind: "python", command: py.command, args: [...py.prefix, relPath], debugAdapter: "debugpy" };
}
if (/\.[cm]?js$/.test(relPath)) return { ...base, command: "node", args: [relPath], debugAdapter: "js-debug" };
if (/\.ts$/.test(relPath)) return { ...base, command: "npx", args: ["tsx", relPath], debugAdapter: "js-debug" };
if (/\.(sh|bash|zsh)$/.test(relPath)) {
const shell = relPath.endsWith(".zsh") ? "zsh" : relPath.endsWith(".bash") ? "bash" : "sh";
return { ...base, command: shell, args: [relPath] };
}
if (windows && /\.(bat|cmd)$/i.test(relPath)) {
const file = relPath.split("/").pop() ?? relPath;
return { ...base, command: "cmd.exe", args: ["/c", file], cwd: dirOf(relPath) || null };
}
return null;
}
/** Config to run for the file at `relPath` (relative to the project root), if any. */
export function contextConfig(
relPath: string,
configs: RunConfig[],
content?: string,
windows = isWindows(),
): RunConfig | null {
const role = fileRole(relPath, content, windows, hasDjangoTests(configs));
if (role === "test") return testConfigFor(relPath, configs);
if (role === "main") return mainConfigFor(relPath, configs) ?? scriptConfigFor(relPath, configs, windows, content);
if (role === "script") return scriptConfigFor(relPath, configs, windows, content);
return null;
}