feat(run): import JetBrains run configurations (.run/*.run.xml, .idea/workspace.xml) (#1039)

Run/Debug Configurations offers Import from JetBrains run configurations when the project has .run/*.run.xml, .idea/runConfigurations/*.xml or run configurations in .idea/workspace.xml; Find Action has Import Run Configurations from JetBrains (.run, .idea). Python, Django server/tests, pytest, npm, Jest, Vitest, Node.js, Shell Script and compound configurations map to the matching kinds; other types, unknown path variables and multi-module $MODULE_DIR$ are skipped with the reason. Shared .run files take precedence over .idea/runConfigurations and the workspace's RunManager (templates and temporary configurations left out). Path and argument helpers shared with the launch.json importer moved to import/mod.rs.

Co-authored-by: andreabasile <[email protected]>
Цей коміт міститься в:
Andrea Basileтаandreabasile authored and GitHub committed 2026-11-04 05:46:50 +08:00
1 джерело df6fbb6d21
коміт 9a6b84e287
11 files changed
+1832 -143

No files matched your search

+2 -2
Переглянути файл
@@ -151,7 +151,7 @@ fn relative(root: &Path, dir: &Path) -> PathBuf {
// ---------------------------------------------------------------- node
fn package_manager(root: &Path, pkg: &Json) -> &'static str {
pub(crate) fn package_manager(root: &Path, pkg: &Json) -> &'static str {
if let Some(pm) = pkg.get("packageManager").and_then(Json::as_str) {
match pm.split('@').next().unwrap_or_default() {
"pnpm" => return "pnpm",
@@ -172,7 +172,7 @@ fn package_manager(root: &Path, pkg: &Json) -> &'static str {
}
}
fn is_test_script(name: &str) -> bool {
pub(crate) fn is_test_script(name: &str) -> bool {
matches!(name, "test" | "vitest" | "jest") || name.starts_with("test:")
}
+879
Переглянути файл
@@ -0,0 +1,879 @@
//! JetBrains run configurations (PyCharm, WebStorm, IntelliJ IDEA): the shared `.run/*.run.xml`
//! files, `.idea/runConfigurations/*.xml`, and the `RunManager` of `.idea/workspace.xml` without
//! its templates (`default="true"`) and temporary configurations. A configuration found in more
//! than one place is taken from the first, in that order.
//!
//! Python, Django server, Django tests, pytest, npm, Jest, Vitest, Node.js, Shell Script and
//! compound configurations become the matching kinds; other types are skipped with their type.
//! `$PROJECT_DIR$` (and `$MODULE_DIR$`, `$MODULE_WORKING_DIR$`, `$ContentRoot$`,
//! `$ProjectFileDir$`, all the project directory here) and `$USER_HOME$` are replaced; another
//! path variable skips the configuration.
use super::{
Entry, Found, SkipReason, command_name, config, env_var, normalize, path_from, project_python, relative_to,
working_dir,
};
use crate::RunConfig;
use ide_core::IdeResult;
use quick_xml::{Reader, XmlVersion, events::Event};
use std::cell::Cell;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// The files read, in order of precedence, with the name the summary gives them.
pub(crate) fn files(root: &Path) -> Vec<(String, PathBuf)> {
let mut out = Vec::new();
for (dir, suffix) in [(".run", ".run.xml"), (".idea/runConfigurations", ".xml")] {
let mut found: Vec<PathBuf> = std::fs::read_dir(root.join(dir))
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.is_file() && p.file_name().is_some_and(|n| n.to_string_lossy().ends_with(suffix)))
.collect();
found.sort();
for p in found {
let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
out.push((format!("{dir}/{name}"), p));
}
}
// IDEA writes a workspace.xml in every project it opens: it counts only with configurations to import.
let workspace = root.join(".idea/workspace.xml");
let has_configurations = std::fs::read_to_string(&workspace).is_ok_and(|text| {
text.contains("RunManager")
&& parse(&text).map_or(true, |doc| configurations(&doc, "RunManager").next().is_some())
});
if has_configurations {
out.push((".idea/workspace.xml".into(), workspace));
}
out
}
/// The configurations of `manager` (`RunManager` in workspace.xml), without IDEA's templates for
/// new configurations and the temporary ones it makes for a single run.
fn configurations<'a>(doc: &'a El, manager: &'a str) -> impl Iterator<Item = &'a El> {
std::iter::once(doc)
.chain(doc.children.iter())
.filter(move |c| c.name == "component" && c.attr("name") == Some(manager))
.flat_map(|component| component.children_named("configuration"))
.filter(|c| c.attr("default") != Some("true") && c.attr("temporary") != Some("true"))
}
pub(crate) fn entries(root: &Path, env: &BTreeMap<String, String>) -> IdeResult<Vec<Entry>> {
let windows = cfg!(windows);
let homes = if windows { ["USERPROFILE", "HOME"] } else { ["HOME", "USERPROFILE"] };
let home = homes.into_iter().find_map(|k| env_var(env, k, windows));
let cx = Cx { root: &normalize(root), home, modules: module_count(root), machine_values: Cell::new(false) };
let mut out = Vec::new();
for (label, path) in files(root) {
let doc = std::fs::read_to_string(&path).map_err(|e| e.to_string()).and_then(|text| parse(&text));
let doc = match doc {
Ok(doc) => doc,
Err(detail) => {
out.push(Entry { name: label, result: Err(SkipReason::InvalidFile { detail }) });
continue;
}
};
let workspace = label == ".idea/workspace.xml";
let manager = if workspace { "RunManager" } else { "ProjectRunConfigurationManager" };
out.extend(configurations(&doc, manager).map(|c| cx.entry(c)));
}
Ok(out)
}
// ------------------------------------------------------------------------- XML
/// An element of the file, with its attributes (values unescaped) and child elements.
#[derive(Debug, Default)]
struct El {
name: String,
attrs: Vec<(String, String)>,
children: Vec<El>,
}
impl El {
fn attr(&self, key: &str) -> Option<&str> {
self.attrs.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str())
}
fn child(&self, name: &str) -> Option<&El> {
self.children.iter().find(|c| c.name == name)
}
fn children_named<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a El> {
self.children.iter().filter(move |c| c.name == name)
}
/// `<option name="KEY" value="…"/>`, a direct child.
fn option(&self, key: &str) -> Option<&str> {
self.children_named("option").find(|o| o.attr("name") == Some(key)).and_then(|o| o.attr("value"))
}
/// `<KEY value="…"/>` (the WebStorm types write their fields so).
fn value(&self, key: &str) -> Option<&str> {
self.child(key).and_then(|c| c.attr("value"))
}
fn flag(&self, key: &str) -> bool {
self.option(key) == Some("true")
}
}
fn parse(xml: &str) -> Result<El, String> {
let mut reader = Reader::from_str(xml);
let mut stack: Vec<El> = vec![El::default()];
loop {
let event = reader.read_event().map_err(|e| e.to_string())?;
match event {
Event::Start(ref tag) | Event::Empty(ref tag) => {
let mut el = El { name: tag.name().as_ref().to_owned(), ..El::default() };
for a in tag.attributes() {
let a = a.map_err(|e| e.to_string())?;
let value = a.normalized_value(XmlVersion::Implicit1_0).map_err(|e| e.to_string())?;
el.attrs.push((a.key.as_ref().to_owned(), value.into_owned()));
}
if matches!(event, Event::Start(_)) {
stack.push(el);
} else if let Some(parent) = stack.last_mut() {
parent.children.push(el);
}
}
Event::End(_) => {
let el = stack.pop().ok_or("unbalanced element")?;
stack.last_mut().ok_or("unbalanced element")?.children.push(el);
}
Event::Eof => break,
_ => {}
}
}
let mut document = stack.pop().filter(|_| stack.is_empty()).ok_or("unclosed element")?;
document.children.pop().ok_or_else(|| "no root element".to_owned())
}
// ------------------------------------------------------------------- mapping
const PYTHON: &str = "PythonConfigurationType";
const DJANGO_SERVER: &str = "Python.DjangoServer";
const DJANGO_TESTS: &str = "DjangoTestsConfigurationType";
const PYTHON_TESTS: &str = "tests";
const NPM: &str = "js.build_tools.npm";
const JEST: &str = "JavaScriptTestRunnerJest";
const VITEST: &str = "JavaScriptTestRunnerVitest";
const NODE: &str = "NodeJSConfigurationType";
const SHELL: &str = "ShConfigurationType";
const COMPOUND: &str = "CompoundRunConfigurationType";
type Res<T> = Result<T, SkipReason>;
/// What a `$NAME$` that isn't a known path macro does.
#[derive(Clone, Copy, PartialEq)]
enum Unknown {
/// Skips the configuration: a path variable defined in the IDE, with no value here.
Skip,
/// Stays as written: script text, where `$` belongs to the shell.
Keep,
}
struct Cx<'a> {
root: &'a Path,
home: Option<String>,
/// Modules `.idea/modules.xml` lists. `$MODULE_DIR$` is the module's content root: the project
/// directory in a single-module project, unknown here with several.
modules: usize,
/// The configuration being converted used `$USER_HOME$`: its values belong to this machine.
machine_values: Cell<bool>,
}
/// A JSON string as the pytest configuration stores its `_new_*` fields (`"\"tests\""`).
fn json_string(raw: &str) -> String {
serde_json::from_str::<String>(raw).unwrap_or_else(|_| raw.to_owned())
}
/// Regex-escaped for Jest / Vitest `-t`.
fn regex_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if r"\^$.|?*+()[]{}".contains(c) {
out.push('\\');
}
out.push(c);
}
out
}
impl Cx<'_> {
/// Replaces the path macros in `s`; another `$NAME$` skips the configuration.
fn expand(&self, s: &str) -> Res<String> {
self.expand_with(s, Unknown::Skip)
}
/// Replaces the path macros in `s`, which IDEA writes in every option of a configuration
/// (script text included); `unknown` says what another `$NAME$` does.
fn expand_with(&self, s: &str, unknown: Unknown) -> Res<String> {
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(start) = rest.find('$') {
let after = &rest[start + 1..];
let len =
after.find('$').filter(|&n| n > 0 && after[..n].chars().all(|c| c.is_ascii_alphanumeric() || c == '_'));
let Some(len) = len else {
out.push_str(&rest[..=start]);
rest = after;
continue;
};
out.push_str(&rest[..start]);
let name = &after[..len];
let value = match name {
"PROJECT_DIR" | "ContentRoot" | "ProjectFileDir" => self.root.to_string_lossy().into_owned(),
// Which module's directory is unknown here when there are several.
"MODULE_DIR" | "MODULE_WORKING_DIR" if self.modules > 1 => {
return Err(SkipReason::SeveralModules { variable: format!("${name}$") });
}
"MODULE_DIR" | "MODULE_WORKING_DIR" => self.root.to_string_lossy().into_owned(),
"USER_HOME" => {
self.machine_values.set(true);
self.home.clone().ok_or(SkipReason::PathVariable { variable: format!("${name}$") })?
}
_ => match unknown {
Unknown::Skip => return Err(SkipReason::PathVariable { variable: format!("${name}$") }),
Unknown::Keep => format!("${name}$"),
},
};
out.push_str(&value);
rest = &after[len + 1..];
}
out.push_str(rest);
Ok(out)
}
fn text(&self, value: Option<&str>) -> Res<Option<String>> {
match value.map(str::trim) {
Some(v) if !v.is_empty() => self.expand(v).map(Some),
_ => Ok(None),
}
}
/// Words of a parameters field, split like IDEA's `ParametersListUtil.parse`.
fn words(&self, value: Option<&str>) -> Res<Vec<String>> {
self.words_quoted(value, Quotes::Double)
}
fn words_quoted(&self, value: Option<&str>, quotes: Quotes) -> Res<Vec<String>> {
Ok(self.text(value)?.map(|v| parse_params(&v, quotes)).unwrap_or_default())
}
/// Words of a command line, paths inside the project relative to `dir`.
fn args(&self, value: Option<&str>, dir: &Path) -> Res<Vec<String>> {
self.args_quoted(value, Quotes::Double, dir)
}
fn args_quoted(&self, value: Option<&str>, quotes: Quotes, dir: &Path) -> Res<Vec<String>> {
Ok(self.words_quoted(value, quotes)?.into_iter().map(|a| path_from(self.root, a, dir)).collect())
}
/// The working directory (absolute) and the configuration's `cwd`.
fn cwd(&self, value: Option<&str>) -> Res<(PathBuf, Option<PathBuf>)> {
Ok(match self.text(value)? {
Some(dir) => working_dir(self.root, &dir),
None => (self.root.to_path_buf(), None),
})
}
fn entry(&self, c: &El) -> Entry {
let name = c.attr("name").unwrap_or_default().trim().to_owned();
self.machine_values.set(false);
let result = if name.is_empty() { Err(SkipReason::Invalid) } else { self.convert(&name, c) };
let result = result.map(|mut found| {
// Values of this machine (its home) stay out of the shared run.toml.
found.config.extras.local = self.machine_values.get();
if let Some(folder) = c.attr("folderName").filter(|f| !f.trim().is_empty()) {
found.config.extras.folder = Some(folder.trim().to_owned());
}
// IDEA's "Allow multiple instances".
found.config.allow_parallel = c.attr("singleton") == Some("false");
if c.child("method").is_some_and(|m| !m.children.is_empty()) {
found.ignored.push("before launch".into());
}
found.ignored.sort();
found
});
Entry { name, result }
}
fn convert(&self, name: &str, c: &El) -> Res<Found> {
let ty = c.attr("type").unwrap_or_default();
let factory = c.attr("factoryName").unwrap_or_default();
let mut ignored = Vec::new();
let mut members = Vec::new();
let config = match ty {
PYTHON => self.python(name, c, &mut ignored)?,
DJANGO_SERVER => self.django_server(name, c, &mut ignored)?,
DJANGO_TESTS => self.django_tests(name, c, &mut ignored)?,
PYTHON_TESTS if matches!(factory, "py.test" | "pytest") => self.pytest(name, c, &mut ignored)?,
NPM => self.npm(name, c, &mut ignored)?,
JEST | VITEST => self.js_tests(name, c, ty == VITEST, &mut ignored)?,
NODE => self.node(name, c, &mut ignored)?,
SHELL => self.shell(name, c)?,
COMPOUND => {
members = c.children_named("toRun").filter_map(|t| t.attr("name")).map(str::to_owned).collect();
config(name, "compound", String::new(), Vec::new(), None)
}
PYTHON_TESTS => return Err(SkipReason::UnsupportedType { type_: format!("{ty} ({factory})") }),
other => return Err(SkipReason::UnsupportedType { type_: other.to_owned() }),
};
Ok(Found { config, members, ignored })
}
// ------------------------------------------------------------- Python
/// The interpreter (`SDK_HOME` when the configuration doesn't use the module's), its
/// options, and the working directory.
fn interpreter(&self, c: &El, ignored: &mut Vec<String>) -> Res<(String, Vec<String>, PathBuf, Option<PathBuf>)> {
let (dir, cwd) = self.cwd(c.option("WORKING_DIRECTORY"))?;
let sdk = if c.flag("IS_MODULE_SDK") { None } else { self.text(c.option("SDK_HOME"))? };
let (exe, mut args) = match sdk {
Some(sdk) => (path_from(self.root, sdk, &dir), Vec::new()),
None => project_python(self.root, &dir),
};
args.extend(self.words(c.option("INTERPRETER_OPTIONS"))?);
// A flag (`AbstractPythonRunConfiguration.java:320-321`): `false` leaves nothing out.
if c.option("RUN_TOOL") == Some("true") {
ignored.push("RUN_TOOL".into());
}
Ok((exe, args, dir, cwd))
}
/// `envs`, `PARENT_ENVS`, `ENV_FILES` and the EnvFile plugin's files.
fn python_env(&self, c: &El, rc: &mut RunConfig, ignored: &mut Vec<String>) -> Res<()> {
self.envs(c, rc);
rc.extras.include_system_env = c.option("PARENT_ENVS") != Some("false");
// PyCharm 2024+: "Paths to .env files", joined with `File.pathSeparator`.
for file in split_env_files(c.option("ENV_FILES").unwrap_or_default(), PATH_SEPARATOR) {
if let Some(file) = self.text(Some(file))? {
rc.extras.env_files.push(path_from(self.root, file, self.root));
}
}
self.envfile_plugin(c, rc, ignored)
}
/// Environment variables, as written: IDEA doesn't put path macros there
/// (`RunConfigurationPathMacroFilter`), and the Python and Shell types pass `$NAME$` through as is.
fn envs(&self, c: &El, rc: &mut RunConfig) {
for env in c.child("envs").into_iter().flat_map(|e| e.children_named("env")) {
if let Some(key) = env.attr("name").filter(|k| !k.is_empty()) {
rc.env.insert(key.to_owned(), env.attr("value").unwrap_or_default().to_owned());
}
}
}
/// The EnvFile plugin (`net.ashald.envfile`): its enabled `.env` entries.
fn envfile_plugin(&self, c: &El, rc: &mut RunConfig, ignored: &mut Vec<String>) -> Res<()> {
let Some(ext) = c.children_named("extension").find(|e| e.attr("name") == Some("net.ashald.envfile")) else {
return Ok(());
};
if !ext.flag("IS_ENABLED") {
return Ok(());
}
for entry in ext.child("ENTRIES").into_iter().flat_map(|e| e.children_named("ENTRY")) {
let path = entry.attr("PATH").unwrap_or_default();
if entry.attr("IS_ENABLED") == Some("false") || path.is_empty() {
continue;
}
if entry.attr("PARSER").is_some_and(|p| p != "env") || entry.attr("IS_EXECUTABLE") == Some("true") {
ignored.push(format!("EnvFile {path}"));
continue;
}
if let Some(file) = self.text(Some(path))? {
rc.extras.env_files.push(path_from(self.root, file, self.root));
}
}
Ok(())
}
fn python(&self, name: &str, c: &El, ignored: &mut Vec<String>) -> Res<RunConfig> {
let (exe, mut args, dir, cwd) = self.interpreter(c, ignored)?;
let Some(script) = self.text(c.option("SCRIPT_NAME"))? else {
return Err(SkipReason::NothingToRun);
};
if c.flag("MODULE_MODE") {
args.extend(["-m".to_owned(), script]);
} else {
args.push(path_from(self.root, script, &dir));
}
args.extend(self.args(c.option("PARAMETERS"), &dir)?);
let mut rc = config(name, "python", exe, args, Some("debugpy"));
rc.cwd = cwd.map(Into::into);
rc.extras.emulate_terminal = c.flag("EMULATE_TERMINAL");
if c.flag("REDIRECT_INPUT") {
ignored.push("REDIRECT_INPUT".into());
}
self.python_env(c, &mut rc, ignored)?;
Ok(rc)
}
/// `manage.py` relative to the working directory: there, at the root, or one level down.
fn manage_py(&self, dir: &Path) -> Res<String> {
let mut candidates = vec![dir.join("manage.py"), self.root.join("manage.py")];
if let Ok(read) = std::fs::read_dir(self.root) {
let mut nested: Vec<PathBuf> =
read.flatten().map(|e| e.path().join("manage.py")).filter(|p| p.is_file()).collect();
if nested.len() == 1 {
candidates.append(&mut nested);
}
}
let found =
candidates.into_iter().find(|p| p.is_file()).ok_or(SkipReason::MissingFile { file: "manage.py".into() })?;
Ok(relative_to(&normalize(&found), dir).unwrap_or_else(|| found.to_string_lossy().into_owned()))
}
fn django_server(&self, name: &str, c: &El, ignored: &mut Vec<String>) -> Res<RunConfig> {
let (exe, mut args, dir, cwd) = self.interpreter(c, ignored)?;
args.push(self.manage_py(&dir)?);
let custom = if c.flag("useCustomRunCommand") { self.text(c.option("customRunCommand"))? } else { None };
args.push(custom.unwrap_or_else(|| "runserver".into()));
let port = self.text(c.option("port"))?;
match (self.text(c.option("host"))?, port) {
(Some(host), port) => args.push(format!("{host}:{}", port.as_deref().unwrap_or("8000"))),
(None, Some(port)) => args.push(port),
(None, None) => {}
}
if c.flag("runNoReload") {
args.push("--noreload".into());
}
args.extend(self.words(c.option("additionalOptions"))?);
for key in ["browserUrl", "launchJavascriptDebuger", "runTestServer"] {
if c.option(key).is_some_and(|v| !v.is_empty() && v != "false") {
ignored.push(key.into());
}
}
let mut rc = config(name, "python", exe, args, Some("debugpy"));
rc.cwd = cwd.map(Into::into);
self.python_env(c, &mut rc, ignored)?;
Ok(rc)
}
fn django_tests(&self, name: &str, c: &El, ignored: &mut Vec<String>) -> Res<RunConfig> {
let (exe, mut args, dir, cwd) = self.interpreter(c, ignored)?;
let manage = self.manage_py(&dir)?;
// The Django tests form runs `manage.py` from the working directory; another place stays a Python run.
let kind = if manage == "manage.py" { "django-test" } else { "python" };
args.extend([manage, "test".to_owned()]);
args.extend(self.words(c.option("TARGET"))?);
if c.flag("CUSTOM_SETTINGS")
&& let Some(settings) = self.text(c.option("SETTINGS_FILE"))?
{
args.extend(["--settings".to_owned(), settings_module(&settings, self.root, &dir)]);
}
if c.flag("USE_OPTIONS") {
args.extend(self.words(c.option("OPTIONS"))?);
}
let mut rc = config(name, kind, exe, args, Some("debugpy"));
rc.cwd = cwd.map(Into::into);
// A test configuration either way: the test tree reads `manage.py test` output in a Python run too.
rc.is_test = true;
self.python_env(c, &mut rc, ignored)?;
Ok(rc)
}
fn pytest(&self, name: &str, c: &El, ignored: &mut Vec<String>) -> Res<RunConfig> {
let (exe, mut args, dir, cwd) = self.interpreter(c, ignored)?;
args.extend(["-m".to_owned(), "pytest".to_owned()]);
let field = |key: &str| c.option(key).map(json_string).filter(|v| !v.trim().is_empty());
// As `PyTestConfiguration` (intellij-community `PyTest.kt:151-153`, `:181-183`): parameters only
// follow a Python target (`name[params]`), keywords go to `-k` unless the target is custom.
let target_type = field("_new_targetType");
if let Some(target) = field("_new_target") {
let target = self.expand(&target)?;
match target_type.as_deref() {
Some("PYTHON") => {
let params = field("_new_parameters").map(|p| format!("[{p}]")).unwrap_or_default();
match self.python_target(&target, &dir) {
Some(node_id) => args.push(format!("{node_id}{params}")),
// Not a module of the project (an installed package): pytest finds it by name.
None => {
args.extend(["--pyargs".to_owned(), target]);
if !params.is_empty() {
ignored.push("_new_parameters".into());
}
}
}
}
Some("CUSTOM") => {}
_ => args.push(path_from(self.root, target, &dir)),
}
}
if target_type.as_deref() != Some("CUSTOM")
&& let Some(keywords) =
field("_new_keywords").map(|k| strip_quotes(&k).to_owned()).filter(|k| !k.is_empty())
{
args.extend(["-k".to_owned(), keywords]);
}
// Single quotes group words here too (`PyTestsShared.kt:671`).
args.extend(self.args_quoted(field("_new_additionalArguments").as_deref(), Quotes::Single, &dir)?);
let mut rc = config(name, "pytest", exe, args, Some("debugpy"));
rc.cwd = cwd.map(Into::into);
rc.is_test = true;
self.python_env(c, &mut rc, ignored)?;
Ok(rc)
}
/// A pytest Python target (`tests.test_money.TestRound.test_eur`) as the node id PyCharm
/// passes (`tests/test_money.py::TestRound::test_eur`, intellij-community `PyTestsShared.kt:366-400`):
/// the longest prefix that is a module on disk (`a/b.py` or `a/b/__init__.py`, from the working
/// directory, then the project root), then the rest as `::` symbols. Relative to `dir`.
fn python_target(&self, dotted: &str, dir: &Path) -> Option<String> {
let parts: Vec<&str> = dotted.split('.').collect();
if parts.iter().any(|p| p.is_empty()) {
return None;
}
for base in [dir, self.root] {
for n in (1..=parts.len()).rev() {
let module = parts[..n].join("/");
let (file, node) = if base.join(format!("{module}.py")).is_file() {
(base.join(format!("{module}.py")), format!("{module}.py"))
} else if base.join(&module).join("__init__.py").is_file() {
(base.join(&module), module.clone())
} else {
continue;
};
let path = if base == dir { node } else { path_from(self.root, file.to_string_lossy().into(), dir) };
return Some(
std::iter::once(path)
.chain(parts[n..].iter().map(|s| (*s).to_owned()))
.collect::<Vec<_>>()
.join("::"),
);
}
}
None
}
// ------------------------------------------------------------- JavaScript
fn node_interpreter(&self, c: &El, ignored: &mut Vec<String>) {
let interpreter = c.value("node-interpreter").or_else(|| c.attr("node-interpreter"));
if interpreter.is_some_and(|i| !i.is_empty() && i != "project") {
ignored.push("node-interpreter".into());
}
}
fn npm(&self, name: &str, c: &El, ignored: &mut Vec<String>) -> Res<RunConfig> {
let package_json = self.text(c.value("package-json"))?;
let dir = match &package_json {
Some(p) => normalize(&self.root.join(p)).parent().map(Path::to_path_buf).unwrap_or(self.root.to_path_buf()),
None => self.root.to_path_buf(),
};
let manager = match self.text(c.value("package-manager"))? {
Some(pm) if ["npm", "pnpm", "yarn", "bun"].contains(&command_name(&pm)) => command_name(&pm).to_owned(),
_ => {
let pkg = std::fs::read_to_string(dir.join("package.json"))
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default();
crate::detect::package_manager(&dir, &pkg).to_owned()
}
};
let command = self.text(c.value("command"))?.unwrap_or_else(|| "run".into());
let scripts: Vec<&str> = c
.child("scripts")
.into_iter()
.flat_map(|s| s.children_named("script"))
.filter_map(|s| s.attr("value"))
.collect();
let mut args = vec![command.clone()];
let mut test = command == "test";
if command == "run" {
let Some(script) = scripts.first() else { return Err(SkipReason::NothingToRun) };
test = crate::detect::is_test_script(script);
args.push((*script).to_owned());
if scripts.len() > 1 {
ignored.push("scripts".into());
}
}
args.extend(self.words(c.value("arguments"))?);
self.node_interpreter(c, ignored);
let mut rc = config(name, "npm", manager, args, Some("js-debug"));
rc.cwd = working_dir(self.root, &dir.to_string_lossy()).1.map(Into::into);
rc.is_test = test;
self.envs(c, &mut rc);
Ok(rc)
}
fn js_tests(&self, name: &str, c: &El, vitest: bool, ignored: &mut Vec<String>) -> Res<RunConfig> {
let (dir, cwd) = self.cwd(c.value("working-dir"))?;
let mut args: Vec<String> = if vitest { vec!["vitest".into(), "run".into()] } else { vec!["jest".into()] };
if !vitest && let Some(config_file) = self.text(c.value("config-file"))? {
args.extend(["--config".to_owned(), path_from(self.root, config_file, &dir)]);
}
args.extend(self.args(c.value(if vitest { "vitest-options" } else { "jest-options" }), &dir)?);
match c.value("scope-kind").unwrap_or("ALL") {
"DIRECTORY" => args.extend(self.text(c.value("test-directory"))?.map(|d| path_from(self.root, d, &dir))),
"TEST_FILE" | "SUITE" | "TEST" => {
args.extend(self.text(c.value("test-file"))?.map(|f| path_from(self.root, f, &dir)));
let names: Vec<&str> = c
.child("test-names")
.into_iter()
.flat_map(|n| n.children_named("test-name"))
.filter_map(|n| n.attr("value"))
.collect();
if !names.is_empty() {
let pattern = names.iter().map(|n| regex_escape(n)).collect::<Vec<_>>().join(" ");
let anchored = if c.value("scope-kind") == Some("TEST") {
format!("^{pattern}$")
} else {
format!("^{pattern}")
};
args.extend(["-t".to_owned(), anchored]);
}
}
_ => {}
}
self.node_interpreter(c, ignored);
let mut rc = config(name, "node", "npx".into(), args, Some("js-debug"));
rc.cwd = cwd.map(Into::into);
rc.is_test = true;
self.envs(c, &mut rc);
Ok(rc)
}
fn node(&self, name: &str, c: &El, ignored: &mut Vec<String>) -> Res<RunConfig> {
let (dir, cwd) = self.cwd(c.attr("working-dir"))?;
let Some(file) = self.text(c.attr("path-to-js-file"))? else {
return Err(SkipReason::NothingToRun);
};
let mut args = self.words(c.attr("node-parameters"))?;
args.push(path_from(self.root, file, &dir));
args.extend(self.args(c.attr("application-parameters"), &dir)?);
self.node_interpreter(c, ignored);
let mut rc = config(name, "node", "node".into(), args, Some("js-debug"));
rc.cwd = cwd.map(Into::into);
self.envs(c, &mut rc);
rc.extras.include_system_env = c.attr("pass-parent-envs") != Some("false");
Ok(rc)
}
// ------------------------------------------------------------- Shell
/// A script file (with its interpreter) or script text, kept as the Shell Script form's fields.
fn shell(&self, name: &str, c: &El) -> Res<RunConfig> {
let (dir, cwd) = self.cwd(c.option("SCRIPT_WORKING_DIRECTORY"))?;
let mut params = BTreeMap::new();
// Both default to true when missing (`ShRunConfiguration.java:148-149`).
let rc = if c.option("EXECUTE_SCRIPT_FILE") != Some("false") {
let Some(path) = self.text(c.option("SCRIPT_PATH"))? else { return Err(SkipReason::NothingToRun) };
let path = path_from(self.root, path, &dir);
let options = self.text(c.option("SCRIPT_OPTIONS"))?.unwrap_or_default();
let interpreter =
self.text(c.option("INTERPRETER_PATH"))?.map(|i| path_from(self.root, i, &dir)).unwrap_or_default();
let interpreter_options = self.text(c.option("INTERPRETER_OPTIONS"))?.unwrap_or_default();
// Run directly, the script is the program: `./run.sh`, so it isn't looked up on the PATH.
let path = if interpreter.is_empty() { dot_slash(path) } else { path };
let (command, args) = if interpreter.is_empty() {
(path.clone(), parse_params(&options, Quotes::Double))
} else {
let mut args = parse_params(&interpreter_options, Quotes::Double);
args.push(path.clone());
args.extend(parse_params(&options, Quotes::Double));
(interpreter.clone(), args)
};
params.extend([
("mode".to_owned(), "file".to_owned()),
("path".to_owned(), path),
("options".to_owned(), options),
("interpreter".to_owned(), interpreter),
("interpreterOptions".to_owned(), interpreter_options),
]);
config(name, "shell", command, args, None)
} else {
// IDEA writes `$PROJECT_DIR$` / `$USER_HOME$` into the text too; any other `$` is the shell's.
let script = self.expand_with(c.option("SCRIPT_TEXT").unwrap_or_default().trim(), Unknown::Keep)?;
if script.is_empty() {
return Err(SkipReason::NothingToRun);
}
params.extend([("mode".to_owned(), "text".to_owned()), ("script".to_owned(), script.clone())]);
config(name, "shell", script, Vec::new(), None)
};
let mut rc = rc;
params.retain(|_, v| !v.is_empty());
rc.extras.params = params;
rc.cwd = cwd.map(Into::into);
rc.extras.emulate_terminal = c.option("EXECUTE_IN_TERMINAL") != Some("false");
self.envs(c, &mut rc);
Ok(rc)
}
}
/// Which quotes group words in a parameters field.
#[derive(Clone, Copy)]
enum Quotes {
Double,
Single,
}
/// IDEA's `ParametersListUtil.parse`: words split at whitespace, grouped by quotes (dropped);
/// a backslash escapes only a quote, so Windows paths (`C:\p\out`) keep theirs.
fn parse_params(line: &str, quotes: Quotes) -> Vec<String> {
let is_quote = |c: char| c == '"' || (matches!(quotes, Quotes::Single) && c == '\'');
let mut out = Vec::new();
let mut token = String::new();
let mut quote: Option<char> = None;
let mut escaped = false;
let mut non_empty = false;
let mut chars = line.trim().chars().peekable();
while let Some(c) = chars.next() {
if quote.map_or(is_quote(c), |q| q == c) {
if !escaped {
quote = if quote.is_some() { None } else { Some(c) };
non_empty = true;
continue;
}
escaped = false;
} else if c.is_whitespace() && quote.is_none() {
if !token.is_empty() || non_empty {
out.push(std::mem::take(&mut token));
non_empty = false;
}
continue;
} else if c == '\\'
&& let Some(&next) = chars.peek()
&& quote.map_or(is_quote(next), |q| q == next)
{
escaped = true;
continue;
}
token.push(c);
}
if !token.is_empty() || non_empty {
out.push(token);
}
out
}
/// A relative path as a program: `run.sh` → `./run.sh` (`../x` and rooted paths stay; `/opt/x`
/// has no drive on Windows but isn't relative either).
fn dot_slash(path: String) -> String {
if Path::new(&path).has_root() || path.starts_with("./") || path.starts_with("../") {
path
} else {
format!("./{path}")
}
}
/// How IDEA joins a configuration's `.env` files (`File.pathSeparator`).
const PATH_SEPARATOR: char = if cfg!(windows) { ';' } else { ':' };
/// The `ENV_FILES` paths, joined with `separator` (intellij-community
/// `AbstractPythonRunConfiguration.java:303-304`, `:337`).
fn split_env_files(value: &str, separator: char) -> impl Iterator<Item = &str> {
value.split(separator).filter(|f| !f.trim().is_empty())
}
/// Keywords without the quotes around them (`"not slow"` → `not slow`).
fn strip_quotes(s: &str) -> &str {
let s = s.trim();
for q in ['"', '\''] {
if let Some(inner) = s.strip_prefix(q).and_then(|r| r.strip_suffix(q)) {
return inner;
}
}
s
}
/// Modules in `.idea/modules.xml` (0 without the file).
fn module_count(root: &Path) -> usize {
let Some(doc) = std::fs::read_to_string(root.join(".idea/modules.xml")).ok().and_then(|t| parse(&t).ok()) else {
return 0;
};
let components = std::iter::once(&doc).chain(doc.children.iter());
components
.filter(|c| c.name == "component" && c.attr("name") == Some("ProjectModuleManager"))
.flat_map(|c| c.children_named("modules"))
.map(|m| m.children_named("module").count())
.sum()
}
/// `--settings` from PyCharm's "Settings" field: a file (`mysite/settings/dev.py`, relative to
/// the project) becomes its module relative to the working directory; a module stays as it is.
fn settings_module(value: &str, root: &Path, dir: &Path) -> String {
let Some(stem) = value.strip_suffix(".py") else { return value.to_owned() };
let file = normalize(&root.join(stem));
let rel = file.strip_prefix(dir).map(Path::to_path_buf).unwrap_or_else(|_| PathBuf::from(stem));
rel.components().map(|c| c.as_os_str().to_string_lossy().into_owned()).collect::<Vec<_>>().join(".")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_elements_attributes_and_escapes() {
let doc = parse(
"<?xml version=\"1.0\"?>\n<project><component name=\"RunManager\"><configuration name=\"a &amp; b\">\
<option name=\"SCRIPT_TEXT\" value=\"echo 1&#10;echo 2\" /></configuration></component></project>",
)
.unwrap();
assert_eq!(doc.name, "project");
let c = &doc.children[0].children[0];
assert_eq!(c.attr("name"), Some("a & b"));
assert_eq!(c.option("SCRIPT_TEXT"), Some("echo 1\necho 2"));
assert!(parse("<a><b></a>").is_err());
}
#[test]
fn env_files_split_on_the_path_separator() {
assert_eq!(
split_env_files("$PROJECT_DIR$/.env:$PROJECT_DIR$/.env.local", ':').collect::<Vec<_>>(),
["$PROJECT_DIR$/.env", "$PROJECT_DIR$/.env.local"]
);
assert_eq!(
split_env_files(r"C:\p\.env;C:\p\.env.local", ';').collect::<Vec<_>>(),
[r"C:\p\.env", r"C:\p\.env.local"]
);
assert_eq!(split_env_files("", ':').count(), 0);
assert_eq!(PATH_SEPARATOR, if cfg!(windows) { ';' } else { ':' });
}
#[test]
fn parameters_split_like_idea() {
let p = |s: &str| parse_params(s, Quotes::Double);
assert_eq!(p(r"--out C:\p\out \\srv\share"), [r"--out", r"C:\p\out", r"\\srv\share"]);
assert_eq!(p(r#" a "b c" "" d\"e "f\"g" "#), ["a", "b c", "", r#"d"e"#, r#"f"g"#]);
assert_eq!(p("'a b'"), ["'a", "b'"]);
assert_eq!(parse_params("-k 'a b' \"c d\"", Quotes::Single), ["-k", "a b", "c d"]);
assert!(p(" ").is_empty());
}
#[test]
fn macros_and_settings_modules() {
let cx =
Cx { root: Path::new("/p"), home: Some("/home/me".into()), modules: 1, machine_values: Cell::new(false) };
assert_eq!(cx.expand("$PROJECT_DIR$/src $5 a$b").unwrap(), "/p/src $5 a$b");
assert_eq!(cx.expand_with("cd $PROJECT_DIR$ && $PATH$", Unknown::Keep).unwrap(), "cd /p && $PATH$");
assert!(!cx.machine_values.get());
assert_eq!(dot_slash("run.sh".into()), "./run.sh");
assert_eq!(dot_slash("../run.sh".into()), "../run.sh");
assert_eq!(dot_slash("/opt/run.sh".into()), "/opt/run.sh");
assert_eq!(cx.expand("$USER_HOME$/.venvs").unwrap(), "/home/me/.venvs");
assert!(cx.machine_values.get());
assert_eq!(
cx.expand("$MAVEN_REPOSITORY$/x"),
Err(SkipReason::PathVariable { variable: "$MAVEN_REPOSITORY$".into() })
);
assert_eq!(
settings_module("src/mysite/settings/dev.py", Path::new("/p"), Path::new("/p/src")),
"mysite.settings.dev"
);
assert_eq!(settings_module("mysite.settings.dev", Path::new("/p"), Path::new("/p")), "mysite.settings.dev");
assert_eq!(json_string("\"tests\""), "tests");
let several = Cx { modules: 2, ..cx };
assert_eq!(
several.expand("$MODULE_DIR$/x"),
Err(SkipReason::SeveralModules { variable: "$MODULE_DIR$".into() })
);
assert_eq!(several.expand("$PROJECT_DIR$/x").unwrap(), "/p/x");
assert_eq!(strip_quotes("\"not slow\""), "not slow");
assert_eq!(strip_quotes("'a or b'"), "a or b");
assert_eq!(strip_quotes("plain"), "plain");
assert_eq!(regex_escape("adds 1+1 (sum)"), r"adds 1\+1 \(sum\)");
}
}
+134 -17
Переглянути файл
@@ -1,8 +1,9 @@
//! Run configurations imported on request from another IDE's project files
//! (`.vscode/launch.json`). Each source turns its entries into [`Entry`]s; [`finish`]
//! gives them ids, skips names that already exist (so importing again adds nothing
//! twice) and resolves compound members by name.
//! (`.vscode/launch.json`, JetBrains `.run/*.run.xml` and `.idea/workspace.xml`). Each source
//! turns its entries into [`Entry`]s; [`finish`] gives them ids, skips names that already exist
//! (so importing again adds nothing twice) and resolves compound members by name.
mod jetbrains;
mod vscode;
use crate::RunConfig;
@@ -12,21 +13,31 @@ use specta::Type;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Component, Path, PathBuf};
/// A project file run configurations can be imported from.
/// Project files run configurations can be imported from.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum RunImportSource {
/// VS Code's `.vscode/launch.json`.
Vscode,
/// JetBrains IDEs' `.run/*.run.xml`, `.idea/runConfigurations/*.xml` and `.idea/workspace.xml`.
Jetbrains,
}
impl RunImportSource {
pub const ALL: [Self; 1] = [Self::Vscode];
pub const ALL: [Self; 2] = [Self::Vscode, Self::Jetbrains];
/// The file read, relative to the project root.
/// The files read, relative to the project root, as the summary names them.
pub fn file(self) -> &'static str {
match self {
Self::Vscode => ".vscode/launch.json",
Self::Jetbrains => ".run, .idea",
}
}
fn is_present(self, root: &Path) -> bool {
match self {
Self::Vscode => root.join(self.file()).is_file(),
Self::Jetbrains => !jetbrains::files(root).is_empty(),
}
}
}
@@ -62,7 +73,7 @@ pub struct SkippedRunConfig {
pub enum SkipReason {
/// A configuration with this name already exists.
NameTaken,
/// The debugger type (`type`) has no equivalent here, e.g. `cppdbg`.
/// The configuration type has no equivalent here: a VS Code debugger (`cppdbg`), a JetBrains type (`Application`).
UnsupportedType {
#[serde(rename = "type")]
type_: String,
@@ -75,6 +86,14 @@ pub enum SkipReason {
Variable { variable: String },
/// No program, module or command.
NothingToRun,
/// A JetBrains path variable with no value here (`$MAVEN_REPOSITORY$`, user-defined ones).
PathVariable { variable: String },
/// `$MODULE_DIR$` in a project with several modules: which module's directory is unknown.
SeveralModules { variable: String },
/// A file the configuration needs isn't in the project (Django's `manage.py`).
MissingFile { file: String },
/// A project file that can't be read, or isn't XML; `name` is the file.
InvalidFile { detail: String },
/// A compound member that is neither imported nor an existing configuration.
MissingMember { member: String },
/// Not an object with a name.
@@ -104,22 +123,19 @@ pub fn import(
env: &BTreeMap<String, String>,
) -> IdeResult<RunImport> {
let file = source.file();
let text = match std::fs::read_to_string(root.join(file)) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(IdeError::not_found(format!("{file} not found")));
}
Err(e) => return Err(e.into()),
};
if !source.is_present(root) {
return Err(IdeError::not_found(format!("{file} not found")));
}
let entries = match source {
RunImportSource::Vscode => vscode::entries(&text, root, env)?,
RunImportSource::Vscode => vscode::entries(&std::fs::read_to_string(root.join(file))?, root, env)?,
RunImportSource::Jetbrains => jetbrains::entries(root, env)?,
};
Ok(finish(source, entries, existing))
}
/// The sources whose file exists under `root`.
/// The sources with files under `root`.
pub fn available(root: &Path) -> Vec<RunImportSource> {
RunImportSource::ALL.into_iter().filter(|s| root.join(s.file()).is_file()).collect()
RunImportSource::ALL.into_iter().filter(|s| s.is_present(root)).collect()
}
fn unique_id(name: &str, ids: &mut HashSet<String>) -> String {
@@ -189,6 +205,107 @@ pub(crate) fn finish(source: RunImportSource, entries: Vec<Entry>, existing: &[R
}
}
// ------------------------------------------------------------- shared by the sources
/// The variable `name` of `env`; Windows names ignore case (`${env:PATH}` finds `Path`).
pub(crate) fn env_var(env: &BTreeMap<String, String>, name: &str, windows: bool) -> Option<String> {
match env.get(name) {
Some(v) => Some(v.clone()),
None if windows => env.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v.clone()),
None => None,
}
}
/// The program's file name without a Windows extension (`npm.cmd` → `npm`).
pub(crate) fn command_name(cmd: &str) -> &str {
let name = cmd.rsplit(['/', '\\']).next().unwrap_or(cmd);
name.strip_suffix(".cmd").or_else(|| name.strip_suffix(".exe")).unwrap_or(name)
}
pub(crate) fn config(name: &str, kind: &str, command: String, args: Vec<String>, debug: Option<&str>) -> RunConfig {
RunConfig {
id: String::new(),
name: name.to_owned(),
kind: kind.to_owned(),
command,
args,
cwd: None,
env: BTreeMap::new(),
detected: false,
is_test: false,
debug_adapter: debug.map(str::to_owned),
allow_parallel: false,
extras: crate::RunExtras::default(),
}
}
/// Splits a command line into words: whitespace, `'…'` and `"…"` quoting, backslash escapes
/// (like `parseArgs` in `src/platform/commandLine.ts`).
pub(crate) fn split_args(line: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut has = false;
let mut quote: Option<char> = None;
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
match quote {
Some(q) if c == q => quote = None,
Some('"') if c == '\\' && chars.peek().is_some_and(|n| "\"\\$`".contains(*n)) => {
cur.extend(chars.next());
}
Some(_) => cur.push(c),
None if c == '\'' || c == '"' => {
quote = Some(c);
has = true;
}
None if c == '\\' && chars.peek().is_some() => {
cur.extend(chars.next());
has = true;
}
None if c.is_whitespace() => {
if has || !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
has = false;
}
None => cur.push(c),
}
}
if has || !cur.is_empty() {
out.push(cur);
}
out
}
/// An absolute path inside the project, written relative to `dir` (the run's working
/// directory, where the process resolves it); anything else as it is.
pub(crate) fn path_from(root: &Path, value: String, dir: &Path) -> String {
let path = Path::new(&value);
if !path.is_absolute() {
return value;
}
let path = normalize(path);
if path.starts_with(root) && dir.starts_with(root) { relative_to(&path, dir).unwrap_or(value) } else { value }
}
/// The project's interpreter (see [`crate::detect::project_python`]), relative to `dir`.
pub(crate) fn project_python(root: &Path, dir: &Path) -> (String, Vec<String>) {
let (exe, lead) = crate::detect::project_python(root);
let exe = if exe.contains('/') { path_from(root, root.join(&exe).to_string_lossy().into(), dir) } else { exe };
(exe, lead)
}
/// The working directory `cwd` (absolute) and the configuration's `cwd` (relative to the root, `None` for the root).
pub(crate) fn working_dir(root: &Path, cwd: &str) -> (PathBuf, Option<PathBuf>) {
let abs = normalize(&root.join(cwd));
let rel = match abs.strip_prefix(root) {
Ok(r) if r.as_os_str().is_empty() => None,
Ok(r) => Some(PathBuf::from(relative_to(&abs, root).unwrap_or_else(|| r.to_string_lossy().into()))),
Err(_) => Some(abs.clone()),
};
(abs, rel)
}
/// `path` as written relative to `base` (both absolute, without `..`), with `/` separators;
/// `None` when they don't share a prefix (another drive).
pub(crate) fn relative_to(path: &Path, base: &Path) -> Option<String> {
+9 -99
Переглянути файл
@@ -6,8 +6,11 @@
//! an entry using one that depends on the moment VS Code launches it (`${file}`,
//! `${input:…}`, `${command:…}`) is skipped.
use super::{Entry, Found, SkipReason, normalize, relative_to};
use crate::{RunConfig, RunExtras};
use super::{
Entry, Found, SkipReason, command_name, config, env_var, normalize, path_from, project_python, split_args,
working_dir,
};
use crate::RunConfig;
use ide_core::{IdeError, IdeResult};
use serde_json::{Map, Value};
use std::cell::Cell;
@@ -87,15 +90,6 @@ fn name_of(v: &Value) -> String {
v.get("name").and_then(Value::as_str).unwrap_or_default().trim().to_owned()
}
/// The variable `name` of `env`; Windows names ignore case (`${env:PATH}` finds `Path`).
fn env_var(env: &BTreeMap<String, String>, name: &str, windows: bool) -> Option<String> {
match env.get(name) {
Some(v) => Some(v.clone()),
None if windows => env.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v.clone()),
None => None,
}
}
/// String form of a scalar (`"args": [8000]`, `"env": { "DEBUG": true }`).
fn scalar(v: &Value) -> Option<String> {
match v {
@@ -106,29 +100,6 @@ fn scalar(v: &Value) -> Option<String> {
}
}
/// The program's file name without a Windows extension (`npm.cmd` → `npm`).
fn command_name(cmd: &str) -> &str {
let name = cmd.rsplit(['/', '\\']).next().unwrap_or(cmd);
name.strip_suffix(".cmd").or_else(|| name.strip_suffix(".exe")).unwrap_or(name)
}
fn config(name: &str, kind: &str, command: String, args: Vec<String>, debug: Option<&str>) -> RunConfig {
RunConfig {
id: String::new(),
name: name.to_owned(),
kind: kind.to_owned(),
command,
args,
cwd: None,
env: BTreeMap::new(),
detected: false,
is_test: false,
debug_adapter: debug.map(str::to_owned),
allow_parallel: false,
extras: RunExtras::default(),
}
}
/// `npm run dev`-style words: an npm configuration's `(command, args)`, `run-script` read as `run`.
fn npm_line(words: &[String]) -> Option<(String, Vec<String>)> {
let (exe, rest) = words.split_first()?;
@@ -142,44 +113,6 @@ fn npm_line(words: &[String]) -> Option<(String, Vec<String>)> {
NPM_COMMANDS.contains(&args.first()?.as_str()).then(|| (exe.clone(), args))
}
/// Splits a command line into words: whitespace, `'…'` and `"…"` quoting, backslash escapes
/// (like `parseArgs` in `src/platform/commandLine.ts`).
fn split_args(line: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut has = false;
let mut quote: Option<char> = None;
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
match quote {
Some(q) if c == q => quote = None,
Some('"') if c == '\\' && chars.peek().is_some_and(|n| "\"\\$`".contains(*n)) => {
cur.extend(chars.next());
}
Some(_) => cur.push(c),
None if c == '\'' || c == '"' => {
quote = Some(c);
has = true;
}
None if c == '\\' && chars.peek().is_some() => {
cur.extend(chars.next());
has = true;
}
None if c.is_whitespace() => {
if has || !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
has = false;
}
None => cur.push(c),
}
}
if has || !cur.is_empty() {
out.push(cur);
}
out
}
impl Cx<'_> {
/// Replaces the variables in `s`.
fn expand(&self, s: &str) -> Res<String> {
@@ -235,16 +168,7 @@ impl Cx<'_> {
/// An absolute path inside the project, written relative to `dir` (the run's working
/// directory, where the process resolves it); anything else as it is.
fn path_from(&self, value: String, dir: &Path) -> String {
let path = Path::new(&value);
if !path.is_absolute() {
return value;
}
let path = normalize(path);
if path.starts_with(self.root) && dir.starts_with(self.root) {
relative_to(&path, dir).unwrap_or(value)
} else {
value
}
path_from(self.root, value, dir)
}
/// The working directory (absolute) and the configuration's `cwd` (relative to the root).
@@ -252,13 +176,7 @@ impl Cx<'_> {
let Some(cwd) = self.string(obj, "cwd")? else {
return Ok((self.root.to_path_buf(), None));
};
let abs = normalize(&self.root.join(cwd));
let rel = match abs.strip_prefix(self.root) {
Ok(r) if r.as_os_str().is_empty() => None,
Ok(r) => Some(PathBuf::from(relative_to(&abs, self.root).unwrap_or_else(|| r.to_string_lossy().into()))),
Err(_) => Some(abs.clone()),
};
Ok((abs, rel))
Ok(working_dir(self.root, &cwd))
}
/// `env` (a variable set to itself is inherited anyway) and `envFile`.
@@ -341,14 +259,14 @@ impl Cx<'_> {
Some((exe, opts)) => {
let opts = opts.iter().map(|o| self.expand(o)).collect::<Res<Vec<_>>>()?;
if exe.trim().is_empty() || selected(exe) {
let (exe, mut lead) = self.project_python(&dir);
let (exe, mut lead) = project_python(self.root, &dir);
lead.extend(opts);
(exe, lead)
} else {
(self.path_from(self.expand(exe)?, &dir), opts)
}
}
None => self.project_python(&dir),
None => project_python(self.root, &dir),
};
args.extend(self.words(obj, "pythonArgs")?);
let rest = self.words(obj, "args")?;
@@ -375,14 +293,6 @@ impl Cx<'_> {
Ok(c)
}
/// The project's interpreter (see [`crate::detect::project_python`]), relative to `dir`.
fn project_python(&self, dir: &Path) -> (String, Vec<String>) {
let (exe, lead) = crate::detect::project_python(self.root);
let exe =
if exe.contains('/') { self.path_from(self.root.join(&exe).to_string_lossy().into(), dir) } else { exe };
(exe, lead)
}
fn python_attach(&self, name: &str, obj: &Map<String, Value>) -> Res<RunConfig> {
if obj.contains_key("processId") || obj.contains_key("listen") {
return Err(SkipReason::UnsupportedAttach);
+691
Переглянути файл
@@ -0,0 +1,691 @@
//! JetBrains run configuration import. The fixtures are synthetic: they follow the structure
//! PyCharm and WebStorm write (`.run/*.run.xml`, `.idea/workspace.xml`), with invented names and values.
use ide_run::RunConfig;
use ide_run::import::{RunImport, RunImportSource, SkipReason, available, import};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
/// IDEA joins `ENV_FILES` with `File.pathSeparator`.
const SEP: char = if cfg!(windows) { ';' } else { ':' };
const VENV_PYTHON: &str = if cfg!(windows) { ".venv/Scripts/python.exe" } else { ".venv/bin/python" };
fn env() -> BTreeMap<String, String> {
BTreeMap::from([("HOME".into(), "/home/me".into()), ("USERPROFILE".into(), "/home/me".into())])
}
fn write(root: &Path, rel: &str, text: &str) {
let path = root.join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, text).unwrap();
}
fn run_file(name: &str, body: &str) -> String {
format!(
"<component name=\"ProjectRunConfigurationManager\">\n {}\n</component>\n",
body.replace("name=\"NAME\"", &format!("name=\"{name}\""))
)
}
fn workspace(configs: &str) -> String {
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n <component name=\"ChangeListManager\" />\n \
<component name=\"RunManager\" selected=\"Python.App\">\n{configs}\n <list>\n <item itemvalue=\"Python.App\" />\n \
</list>\n </component>\n</project>\n"
)
}
fn import_from(root: &Path, existing: &[RunConfig]) -> RunImport {
import(root, RunImportSource::Jetbrains, existing, &env()).unwrap()
}
fn get<'a>(r: &'a RunImport, name: &str) -> &'a RunConfig {
&r.imported.iter().find(|c| c.config.name == name).unwrap_or_else(|| panic!("{name} not imported: {r:#?}")).config
}
fn ignored<'a>(r: &'a RunImport, name: &str) -> &'a [String] {
&r.imported.iter().find(|c| c.config.name == name).unwrap().ignored
}
fn reason<'a>(r: &'a RunImport, name: &str) -> &'a SkipReason {
&r.skipped.iter().find(|s| s.name == name).unwrap_or_else(|| panic!("{name} not skipped: {r:#?}")).reason
}
fn s(v: &[&str]) -> Vec<String> {
v.iter().map(|a| (*a).to_owned()).collect()
}
fn cwd(c: &RunConfig) -> Option<PathBuf> {
c.cwd.as_ref().map(|p| p.as_ref().to_path_buf())
}
/// A Django project in `src/` with a root virtualenv.
fn django_project() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "src/manage.py", "");
write(dir.path(), VENV_PYTHON, "");
dir
}
const PY_COMMON: &str = r#"<module name="shop" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<option name="SDK_HOME" value="" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />"#;
#[test]
fn django_server_and_tests_from_shared_run_files() {
let dir = django_project();
write(
dir.path(),
".run/Server.run.xml",
&run_file(
"Server",
&format!(
r#"<configuration default="false" name="NAME" type="Python.DjangoServer" factoryName="Django server">
{PY_COMMON}
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
<env name="DJANGO_SETTINGS_MODULE" value="shop.settings.dev" />
</envs>
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/src" />
<option name="launchJavascriptDebuger" value="false" />
<option name="port" value="8001" />
<option name="host" value="0.0.0.0" />
<option name="additionalOptions" value="--insecure" />
<option name="browserUrl" value="" />
<option name="runTestServer" value="false" />
<option name="runNoReload" value="true" />
<option name="useCustomRunCommand" value="true" />
<option name="customRunCommand" value="runserver_plus" />
<method v="2" />
</configuration>"#
),
),
);
write(
dir.path(),
".run/Tests.run.xml",
&run_file(
"Tests",
&format!(
r#"<configuration default="false" name="NAME" type="DjangoTestsConfigurationType">
{PY_COMMON}
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/src" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="TARGET" value="orders.tests catalog" />
<option name="SETTINGS_FILE" value="$PROJECT_DIR$/src/shop/settings/test.py" />
<option name="CUSTOM_SETTINGS" value="true" />
<option name="USE_OPTIONS" value="false" />
<option name="OPTIONS" value="--keepdb" />
<method v="2" />
</configuration>"#
),
),
);
let r = import_from(dir.path(), &[]);
assert!(r.skipped.is_empty(), "{r:#?}");
assert_eq!(r.file, ".run, .idea");
let server = get(&r, "Server");
assert_eq!(server.kind, "python");
assert_eq!(server.command, format!("../{VENV_PYTHON}"));
assert_eq!(server.args, s(&["manage.py", "runserver_plus", "0.0.0.0:8001", "--noreload", "--insecure"]));
assert_eq!(cwd(server), Some(PathBuf::from("src")));
assert_eq!(server.env["DJANGO_SETTINGS_MODULE"], "shop.settings.dev");
assert_eq!(server.debug_adapter.as_deref(), Some("debugpy"));
assert!(server.extras.include_system_env && !server.extras.local);
let tests = get(&r, "Tests");
assert_eq!(tests.kind, "django-test");
assert!(tests.is_test);
// OPTIONS count only with USE_OPTIONS, as in PyCharm; the settings file becomes its module.
assert_eq!(tests.args, s(&["manage.py", "test", "orders.tests", "catalog", "--settings", "shop.settings.test"]));
}
#[test]
fn workspace_run_manager_skips_templates_and_temporary_configurations() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "web/package.json", r#"{ "name": "web", "scripts": { "dev": "vite", "test": "vitest" } }"#);
write(dir.path(), "web/pnpm-lock.yaml", "");
write(dir.path(), "tests/__init__.py", "");
write(dir.path(), "tests/test_money.py", "");
write(
dir.path(),
".idea/workspace.xml",
&workspace(&format!(
r#" <configuration default="true" type="PythonConfigurationType" factoryName="Python">
{PY_COMMON}
<option name="SCRIPT_NAME" value="" />
<method v="2" />
</configuration>
<configuration name="build" type="js.build_tools.npm" temporary="true" nameIsGenerated="true">
<package-json value="$PROJECT_DIR$/web/package.json" />
<command value="run" />
<scripts><script value="build" /></scripts>
<node-interpreter value="project" />
<envs />
<method v="2" />
</configuration>
<configuration name="App" type="PythonConfigurationType" factoryName="Python" folderName="Tools" singleton="false">
<module name="shop" />
<option name="ENV_FILES" value="$PROJECT_DIR$/.env{SEP}$PROJECT_DIR$/.env.local" />
<option name="INTERPRETER_OPTIONS" value="-X dev" />
<option name="PARENT_ENVS" value="false" />
<envs><env name="LOG_LEVEL" value="debug" /></envs>
<option name="SDK_HOME" value="$USER_HOME$/.virtualenvs/shop/bin/python" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="false" />
<option name="RUN_TOOL" value="" />
<option name="SCRIPT_NAME" value="shop.tools.sync" />
<option name="PARAMETERS" value="--since 2024-01-01 --out $PROJECT_DIR$/out" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="true" />
<option name="MODULE_MODE" value="true" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2">
<option name="RunConfigurationTask" enabled="true" run_configuration_name="Migrate" run_configuration_type="PythonConfigurationType" />
</method>
</configuration>
<configuration name="Unit" type="tests" factoryName="py.test">
{PY_COMMON}
<option name="WORKING_DIRECTORY" value="" />
<option name="_new_keywords" value="&quot;not slow&quot;" />
<option name="_new_parameters" value="&quot;&quot;" />
<option name="_new_additionalArguments" value="&quot;-x --tb=short&quot;" />
<option name="_new_target" value="&quot;$PROJECT_DIR$/tests/test_orders.py&quot;" />
<option name="_new_targetType" value="&quot;PATH&quot;" />
<method v="2" />
</configuration>
<configuration name="Case" type="tests" factoryName="py.test">
{PY_COMMON}
<option name="_new_parameters" value="&quot;eur-2&quot;" />
<option name="_new_target" value="&quot;tests.test_money.test_round&quot;" />
<option name="_new_targetType" value="&quot;PYTHON&quot;" />
<method v="2" />
</configuration>
<configuration name="Path params" type="tests" factoryName="py.test">
{PY_COMMON}
<option name="_new_keywords" value="&quot;'slow or fast'&quot;" />
<option name="_new_parameters" value="&quot;eur-2&quot;" />
<option name="_new_target" value="&quot;$PROJECT_DIR$/tests/test_money.py&quot;" />
<option name="_new_targetType" value="&quot;PATH&quot;" />
<method v="2" />
</configuration>
<configuration name="Custom" type="tests" factoryName="py.test">
{PY_COMMON}
<option name="_new_keywords" value="&quot;slow&quot;" />
<option name="_new_additionalArguments" value="&quot;tests/a.py --maxfail=2&quot;" />
<option name="_new_target" value="&quot;&quot;" />
<option name="_new_targetType" value="&quot;CUSTOM&quot;" />
<method v="2" />
</configuration>
<configuration name="Unittests in tests" type="tests" factoryName="Unittests">
{PY_COMMON}
<option name="_new_target" value="&quot;tests&quot;" />
<method v="2" />
</configuration>
<configuration name="dev" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/web/package.json" />
<command value="run" />
<scripts><script value="dev" /><script value="test" /></scripts>
<arguments value="--host 0.0.0.0" />
<node-interpreter value="/opt/node22/bin/node" />
<envs><env name="VITE_API" value="http://localhost:8000" /></envs>
<method v="2" />
</configuration>
<configuration name="Main" type="Application" factoryName="Application">
<option name="MAIN_CLASS_NAME" value="shop.Main" />
<method v="2" />
</configuration>"#
)),
);
let r = import_from(dir.path(), &[]);
let names: Vec<&str> = r.imported.iter().map(|c| c.config.name.as_str()).collect();
assert_eq!(names, ["App", "Unit", "Case", "Path params", "Custom", "dev"]);
let app = get(&r, "App");
// `$USER_HOME$` is this machine's: the configuration goes to run.local.toml.
assert!(app.extras.local);
assert_eq!(app.command, "/home/me/.virtualenvs/shop/bin/python");
assert_eq!(app.args, s(&["-X", "dev", "-m", "shop.tools.sync", "--since", "2024-01-01", "--out", "out"]));
assert_eq!(app.extras.env_files, s(&[".env", ".env.local"]));
assert!(!app.extras.include_system_env && app.extras.emulate_terminal && app.allow_parallel);
assert_eq!(app.extras.folder.as_deref(), Some("Tools"));
assert_eq!(ignored(&r, "App"), ["before launch"]);
let unit = get(&r, "Unit");
assert_eq!(unit.kind, "pytest");
assert!(unit.is_test);
let py = if cfg!(windows) { "python" } else { "python3" };
assert_eq!(unit.command, py);
assert_eq!(unit.args, s(&["-m", "pytest", "tests/test_orders.py", "-k", "not slow", "-x", "--tb=short"]));
// intellij-community PyTest.kt: parameters follow only a Python target; keywords lose their
// quotes and are left out for a custom target; additional arguments split like a command line.
assert_eq!(get(&r, "Case").args, s(&["-m", "pytest", "tests/test_money.py::test_round[eur-2]"]));
assert_eq!(get(&r, "Path params").args, s(&["-m", "pytest", "tests/test_money.py", "-k", "slow or fast"]));
assert_eq!(get(&r, "Custom").args, s(&["-m", "pytest", "tests/a.py", "--maxfail=2"]));
assert_eq!(reason(&r, "Unittests in tests"), &SkipReason::UnsupportedType { type_: "tests (Unittests)".into() });
assert_eq!(reason(&r, "Main"), &SkipReason::UnsupportedType { type_: "Application".into() });
let dev = get(&r, "dev");
assert_eq!((dev.kind.as_str(), dev.command.as_str()), ("npm", "pnpm"));
assert_eq!(dev.args, s(&["run", "dev", "--host", "0.0.0.0"]));
assert_eq!(cwd(dev), Some(PathBuf::from("web")));
assert_eq!(dev.env["VITE_API"], "http://localhost:8000");
assert_eq!(ignored(&r, "dev"), ["node-interpreter", "scripts"]);
// Neither the template nor the temporary `build` is listed as skipped.
assert_eq!(r.skipped.len(), 2);
}
#[test]
fn javascript_tests_node_shell_and_compounds() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path(),
".idea/workspace.xml",
&workspace(
r#" <configuration name="cart total" type="JavaScriptTestRunnerVitest">
<node-interpreter value="project" />
<vitest-package value="$PROJECT_DIR$/web/node_modules/vitest" />
<working-dir value="$PROJECT_DIR$/web" />
<vitest-options value="--reporter verbose" />
<envs />
<scope-kind value="TEST" />
<test-file value="$PROJECT_DIR$/web/src/cart.test.ts" />
<test-names>
<test-name value="cart" />
<test-name value="total (with tax)" />
</test-names>
<method v="2" />
</configuration>
<configuration name="api tests" type="JavaScriptTestRunnerJest">
<node-interpreter value="project" />
<jest-package value="$PROJECT_DIR$/node_modules/jest" />
<working-dir value="$PROJECT_DIR$" />
<jest-options value="--runInBand" />
<config-file value="$PROJECT_DIR$/jest.config.js" />
<envs><env name="NODE_ENV" value="test" /></envs>
<scope-kind value="DIRECTORY" />
<test-directory value="$PROJECT_DIR$/api/__tests__" />
<method v="2" />
</configuration>
<configuration name="server.js" type="NodeJSConfigurationType" path-to-js-file="server/index.js" working-dir="$PROJECT_DIR$" node-parameters="--enable-source-maps" application-parameters="--port 3000">
<envs><env name="PORT" value="3000" /></envs>
<method v="2" />
</configuration>
<configuration name="seed" type="ShConfigurationType">
<option name="SCRIPT_TEXT" value="" />
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
<option name="SCRIPT_PATH" value="$PROJECT_DIR$/scripts/seed.sh" />
<option name="SCRIPT_OPTIONS" value="--small" />
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="INDEPENDENT_INTERPRETER_PATH" value="true" />
<option name="INTERPRETER_PATH" value="/bin/bash" />
<option name="INTERPRETER_OPTIONS" value="-e" />
<option name="EXECUTE_IN_TERMINAL" value="true" />
<option name="EXECUTE_SCRIPT_FILE" value="true" />
<envs />
<method v="2" />
</configuration>
<configuration name="clean" type="ShConfigurationType">
<option name="SCRIPT_TEXT" value="cd $PROJECT_DIR$/web &amp;&amp; npm run dev &amp;&amp; echo $HOME$PATH $1 $HOME" />
<option name="EXECUTE_IN_TERMINAL" value="false" />
<option name="EXECUTE_SCRIPT_FILE" value="false" />
<envs />
<method v="2" />
</configuration>
<configuration name="Stack" type="CompoundRunConfigurationType">
<toRun name="server.js" type="NodeJSConfigurationType" />
<toRun name="seed" type="ShConfigurationType" />
<method v="2" />
</configuration>
<configuration name="Broken" type="CompoundRunConfigurationType">
<toRun name="Gone" type="NodeJSConfigurationType" />
<method v="2" />
</configuration>"#,
),
);
let r = import_from(dir.path(), &[]);
let vitest = get(&r, "cart total");
assert_eq!((vitest.kind.as_str(), vitest.command.as_str()), ("node", "npx"));
assert_eq!(
vitest.args,
s(&["vitest", "run", "--reporter", "verbose", "src/cart.test.ts", "-t", r"^cart total \(with tax\)$"])
);
assert_eq!(cwd(vitest), Some(PathBuf::from("web")));
assert!(vitest.is_test);
let jest = get(&r, "api tests");
assert_eq!(jest.args, s(&["jest", "--config", "jest.config.js", "--runInBand", "api/__tests__"]));
assert_eq!(jest.env["NODE_ENV"], "test");
let node = get(&r, "server.js");
assert_eq!((node.kind.as_str(), node.command.as_str()), ("node", "node"));
assert_eq!(node.args, s(&["--enable-source-maps", "server/index.js", "--port", "3000"]));
assert_eq!(node.debug_adapter.as_deref(), Some("js-debug"));
let seed = get(&r, "seed");
assert_eq!((seed.kind.as_str(), seed.command.as_str()), ("shell", "/bin/bash"));
assert_eq!(seed.args, s(&["-e", "scripts/seed.sh", "--small"]));
assert_eq!(seed.extras.params["mode"], "file");
assert_eq!(seed.extras.params["path"], "scripts/seed.sh");
assert!(seed.extras.emulate_terminal);
// Script text is the shell's own: `$HOME$` there is not a macro.
let clean = get(&r, "clean");
// IDEA writes `$PROJECT_DIR$` into script text too; the shell's own `$HOME`, `$1`, `$HOME$PATH` stay.
let root = dir.path().to_string_lossy();
let line = format!("cd {root}/web && npm run dev && echo $HOME$PATH $1 $HOME");
assert_eq!(clean.command, line);
assert_eq!(clean.extras.params["script"], line);
assert_eq!(clean.extras.params["mode"], "text");
assert!(!clean.extras.local);
let stack = get(&r, "Stack");
assert_eq!(stack.kind, "compound");
assert_eq!(stack.extras.compound, s(&["user:server-js", "user:seed"]));
assert_eq!(reason(&r, "Broken"), &SkipReason::MissingMember { member: "Gone".into() });
}
#[test]
fn shared_files_win_and_reimport_adds_nothing() {
let dir = django_project();
let tests = |name: &str, target: &str| {
run_file(
name,
&format!(
r#"<configuration name="NAME" type="DjangoTestsConfigurationType">
{PY_COMMON}
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/src" />
<option name="TARGET" value="{target}" />
<method v="2" />
</configuration>"#
),
)
};
write(dir.path(), ".run/Tests.run.xml", &tests("Tests", "shared"));
write(dir.path(), ".idea/runConfigurations/Other.xml", &tests("Other", "other"));
let mine = tests("Tests", "mine").replace("ProjectRunConfigurationManager", "RunManager");
write(dir.path(), ".idea/workspace.xml", &format!("<project version=\"4\">{mine}</project>"));
write(dir.path(), ".run/Broken.run.xml", "<component name=\"ProjectRunConfigurationManager\"><configuration");
let r = import_from(dir.path(), &[]);
assert_eq!(get(&r, "Tests").args.last().map(String::as_str), Some("shared"));
assert_eq!(get(&r, "Other").args.last().map(String::as_str), Some("other"));
assert_eq!(reason(&r, "Tests"), &SkipReason::NameTaken);
// The parser's message says where the file breaks.
assert!(matches!(reason(&r, ".run/Broken.run.xml"), SkipReason::InvalidFile { detail } if !detail.is_empty()));
let existing: Vec<RunConfig> = r.imported.iter().map(|c| c.config.clone()).collect();
assert!(import_from(dir.path(), &existing).imported.is_empty());
}
#[test]
fn macros_env_files_and_missing_manage_py() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path(),
".run/Sync.run.xml",
&run_file(
"Sync",
&format!(
r#"<configuration name="NAME" type="PythonConfigurationType" factoryName="Python">
{PY_COMMON}
<option name="WORKING_DIRECTORY" value="$MODULE_DIR$/jobs" />
<option name="SCRIPT_NAME" value="$MODULE_WORKING_DIR$/jobs/sync.py" />
<option name="PARAMETERS" value="" />
<extension name="net.ashald.envfile">
<option name="IS_ENABLED" value="true" />
<option name="IS_SUBST" value="false" />
<ENTRIES>
<ENTRY IS_ENABLED="true" PARSER="runconfig" IS_EXECUTABLE="false" />
<ENTRY IS_ENABLED="true" PARSER="env" IS_EXECUTABLE="false" PATH=".env" />
<ENTRY IS_ENABLED="false" PARSER="env" IS_EXECUTABLE="false" PATH=".env.off" />
<ENTRY IS_ENABLED="true" PARSER="yaml" IS_EXECUTABLE="false" PATH="secrets.yml" />
</ENTRIES>
</extension>
<method v="2" />
</configuration>"#
),
),
);
write(
dir.path(),
".run/Repo.run.xml",
&run_file(
"Repo",
&format!(
r#"<configuration name="NAME" type="PythonConfigurationType" factoryName="Python">
{PY_COMMON}
<option name="SCRIPT_NAME" value="$MAVEN_REPOSITORY$/tool.py" />
<method v="2" />
</configuration>"#
),
),
);
write(
dir.path(),
".run/Server.run.xml",
&run_file(
"Server",
&format!(
r#"<configuration name="NAME" type="Python.DjangoServer" factoryName="Django server">
{PY_COMMON}
<method v="2" />
</configuration>"#
),
),
);
let r = import_from(dir.path(), &[]);
let sync = get(&r, "Sync");
assert_eq!(cwd(sync), Some(PathBuf::from("jobs")));
assert_eq!(sync.args, s(&["sync.py"]));
assert_eq!(sync.extras.env_files, s(&[".env"]));
assert_eq!(ignored(&r, "Sync"), ["EnvFile secrets.yml"]);
assert!(!sync.extras.local);
assert_eq!(reason(&r, "Repo"), &SkipReason::PathVariable { variable: "$MAVEN_REPOSITORY$".into() });
assert_eq!(reason(&r, "Server"), &SkipReason::MissingFile { file: "manage.py".into() });
}
#[test]
fn sources_are_offered_when_their_files_exist() {
let dir = tempfile::tempdir().unwrap();
assert!(available(dir.path()).is_empty());
assert!(import(dir.path(), RunImportSource::Jetbrains, &[], &env()).is_err());
// IDEA writes a workspace.xml everywhere: without configurations to import there's nothing to offer.
write(dir.path(), ".idea/workspace.xml", &workspace(""));
assert!(available(dir.path()).is_empty());
write(
dir.path(),
".idea/workspace.xml",
&workspace(
r#"<configuration default="true" type="PythonConfigurationType" factoryName="Python" />
<configuration name="once" type="js.build_tools.npm" temporary="true" />"#,
),
);
assert!(available(dir.path()).is_empty());
write(dir.path(), ".idea/workspace.xml", &workspace(r#"<configuration name="App" type="Application" />"#));
assert_eq!(available(dir.path()), [RunImportSource::Jetbrains]);
write(dir.path(), ".vscode/launch.json", "{}");
assert_eq!(available(dir.path()), [RunImportSource::Vscode, RunImportSource::Jetbrains]);
assert_eq!(
reason(&import_from(dir.path(), &[]), "App"),
&SkipReason::UnsupportedType { type_: "Application".into() }
);
}
#[test]
fn imported_configurations_round_trip_through_run_toml() {
let dir = django_project();
write(
dir.path(),
".run/Seed.run.xml",
&run_file(
"Seed",
r#"<configuration name="NAME" type="ShConfigurationType" folderName="Data">
<option name="SCRIPT_PATH" value="$PROJECT_DIR$/scripts/seed.sh" />
<option name="INTERPRETER_PATH" value="" />
<option name="EXECUTE_SCRIPT_FILE" value="true" />
<envs />
<method v="2" />
</configuration>"#,
),
);
let r = import_from(dir.path(), &[]);
let configs: Vec<RunConfig> = r.imported.iter().map(|c| c.config.clone()).collect();
assert_eq!(configs[0].command, "./scripts/seed.sh");
ide_run::save_user_configs(dir.path(), &configs).unwrap();
assert_eq!(ide_run::load_user_configs(dir.path()).unwrap(), configs);
}
#[test]
fn module_macros_need_a_single_module_project() {
let launch = |root: &Path| {
write(
root,
".run/Job.run.xml",
&run_file(
"Job",
&format!(
r#"<configuration name="NAME" type="PythonConfigurationType" factoryName="Python">
{PY_COMMON}
<option name="SCRIPT_NAME" value="$MODULE_DIR$/job.py" />
<method v="2" />
</configuration>"#
),
),
)
};
let modules = |names: &[&str]| {
let items: String = names
.iter()
.map(|n| format!("<module fileurl=\"file://$PROJECT_DIR$/{n}.iml\" filepath=\"$PROJECT_DIR$/{n}.iml\" />"))
.collect();
format!(
"<project version=\"4\"><component name=\"ProjectModuleManager\"><modules>{items}</modules></component></project>"
)
};
let one = tempfile::tempdir().unwrap();
launch(one.path());
write(one.path(), ".idea/modules.xml", &modules(&["shop"]));
assert_eq!(get(&import_from(one.path(), &[]), "Job").args, s(&["job.py"]));
let two = tempfile::tempdir().unwrap();
launch(two.path());
write(two.path(), ".idea/modules.xml", &modules(&["shop", "worker"]));
let r = import_from(two.path(), &[]);
assert_eq!(reason(&r, "Job"), &SkipReason::SeveralModules { variable: "$MODULE_DIR$".into() });
}
#[test]
fn pytest_python_targets_become_node_ids() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "tests/__init__.py", "");
write(dir.path(), "tests/test_money.py", "");
write(dir.path(), "api/tests/test_views.py", "");
write(dir.path(), "pkg/sub/__init__.py", "");
let case = |name: &str, target: &str, params: &str, wd: &str| {
format!(
r#"<configuration name="{name}" type="tests" factoryName="py.test">
{PY_COMMON}
<option name="WORKING_DIRECTORY" value="{wd}" />
<option name="_new_parameters" value="&quot;{params}&quot;" />
<option name="_new_target" value="&quot;{target}&quot;" />
<option name="_new_targetType" value="&quot;PYTHON&quot;" />
<method v="2" />
</configuration>"#
)
};
let configs = [
case("Function", "tests.test_money.test_round", "", ""),
case("Method", "tests.test_money.TestRound.test_eur", "", ""),
case("Parametrized", "tests.test_money.TestRound.test_eur", "eur-2", ""),
case("Module", "tests.test_money", "", ""),
case("Package", "pkg.sub", "", ""),
case("From api", "tests.test_views.test_list", "", "$PROJECT_DIR$/api"),
case("Root from api", "tests.test_money.test_round", "", "$PROJECT_DIR$/api/tests"),
case("Installed", "somelib.tests.test_core", "x", ""),
];
write(dir.path(), ".idea/workspace.xml", &workspace(&configs.join("\n")));
let r = import_from(dir.path(), &[]);
let target = |name: &str| get(&r, name).args[2..].to_vec();
assert_eq!(target("Function"), s(&["tests/test_money.py::test_round"]));
assert_eq!(target("Method"), s(&["tests/test_money.py::TestRound::test_eur"]));
assert_eq!(target("Parametrized"), s(&["tests/test_money.py::TestRound::test_eur[eur-2]"]));
assert_eq!(target("Module"), s(&["tests/test_money.py"]));
assert_eq!(target("Package"), s(&["pkg/sub"]));
// From the working directory first, then the project root (relative to the working directory).
assert_eq!(target("From api"), s(&["tests/test_views.py::test_list"]));
assert_eq!(target("Root from api"), s(&["../../tests/test_money.py::test_round"]));
// Not in the project: pytest looks the module up by name; the parameters can't follow it.
assert_eq!(target("Installed"), s(&["--pyargs", "somelib.tests.test_core"]));
assert_eq!(ignored(&r, "Installed"), ["_new_parameters"]);
}
#[test]
fn flags_defaults_inherited_variables_and_nested_django_tests() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "backend/manage.py", "");
write(
dir.path(),
".idea/workspace.xml",
&workspace(&format!(
r#" <configuration name="Old script" type="ShConfigurationType">
<option name="SCRIPT_PATH" value="$PROJECT_DIR$/run.sh" />
<option name="INTERPRETER_PATH" value="" />
<envs><env name="PYTHONPATH" value="$PYTHONPATH$:src" /><env name="TAG" value="$NOT_SET$" /></envs>
<method v="2" />
</configuration>
<configuration name="Tool off" type="PythonConfigurationType" factoryName="Python">
{PY_COMMON}
<option name="RUN_TOOL" value="false" />
<option name="SCRIPT_NAME" value="app.py" />
<method v="2" />
</configuration>
<configuration name="Tool on" type="PythonConfigurationType" factoryName="Python">
{PY_COMMON}
<option name="RUN_TOOL" value="true" />
<option name="SCRIPT_NAME" value="app.py" />
<method v="2" />
</configuration>
<configuration name="Backend tests" type="DjangoTestsConfigurationType">
{PY_COMMON}
<option name="WORKING_DIRECTORY" value="" />
<option name="TARGET" value="orders" />
<method v="2" />
</configuration>"#
)),
);
let mut env = env();
env.insert("PYTHONPATH".into(), "/opt/lib".into());
let r = import(dir.path(), RunImportSource::Jetbrains, &[], &env).unwrap();
// Written before EXECUTE_SCRIPT_FILE / EXECUTE_IN_TERMINAL existed: both default to true.
let old = get(&r, "Old script");
// Run directly: `./run.sh`, not a `run.sh` looked up on the PATH.
assert_eq!(old.command, "./run.sh");
assert_eq!(old.extras.params["path"], "./run.sh");
assert!(old.extras.emulate_terminal);
// Environment values as written, as the Shell and Python types pass them: `$NAME$` isn't inlined,
// even for a variable runs inherit, and doesn't make the configuration this machine's.
assert_eq!(old.env["PYTHONPATH"], "$PYTHONPATH$:src");
assert_eq!(old.env["TAG"], "$NOT_SET$");
assert!(!old.extras.local);
assert!(ignored(&r, "Tool off").is_empty());
assert_eq!(ignored(&r, "Tool on"), ["RUN_TOOL"]);
// `backend/manage.py test` from the root: a Python run, still a test configuration.
let tests = get(&r, "Backend tests");
assert_eq!((tests.kind.as_str(), tests.is_test), ("python", true));
assert_eq!(tests.args[..3], s(&["backend/manage.py", "test", "orders"]));
}
+13 -3
Переглянути файл
@@ -5820,10 +5820,12 @@ export type RunId = number;
/** What an import found: configurations to add and entries left out, with the reason. */
export type RunImport = RunImport_Serialize | RunImport_Deserialize;
/** A project file run configurations can be imported from. */
/** Project files run configurations can be imported from. */
export type RunImportSource =
/** VS Code's `.vscode/launch.json`. */
"vscode";
"vscode" |
/** JetBrains IDEs' `.run/*.run.xml`, `.idea/runConfigurations/*.xml` and `.idea/workspace.xml`. */
"jetbrains";
/** What an import found: configurations to add and entries left out, with the reason. */
export type RunImport_Deserialize = {
@@ -6205,7 +6207,7 @@ export type SignatureHelp = {
export type SkipReason =
/** A configuration with this name already exists. */
{ kind: "nameTaken" } |
/** The debugger type (`type`) has no equivalent here, e.g. `cppdbg`. */
/** The configuration type has no equivalent here: a VS Code debugger (`cppdbg`), a JetBrains type (`Application`). */
{ kind: "unsupportedType"; type: string } |
/** A `request` other than `launch` and `attach`. */
{ kind: "unsupportedRequest"; request: string } |
@@ -6215,6 +6217,14 @@ export type SkipReason =
{ kind: "variable"; variable: string } |
/** No program, module or command. */
{ kind: "nothingToRun" } |
/** A JetBrains path variable with no value here (`$MAVEN_REPOSITORY$`, user-defined ones). */
{ kind: "pathVariable"; variable: string } |
/** `$MODULE_DIR$` in a project with several modules: which module's directory is unknown. */
{ kind: "severalModules"; variable: string } |
/** A file the configuration needs isn't in the project (Django's `manage.py`). */
{ kind: "missingFile"; file: string } |
/** A project file that can't be read, or isn't XML; `name` is the file. */
{ kind: "invalidFile"; detail: string } |
/** A compound member that is neither imported nor an existing configuration. */
{ kind: "missingMember"; member: string } |
/** Not an object with a name. */
+13
Переглянути файл
@@ -77,4 +77,17 @@ describe("Find Action › Import Run Configurations from .vscode/launch.json", (
expect(ipc.runImportConfigs).not.toHaveBeenCalled();
expect(notes.at(-1)).toMatchObject({ level: "info", title: "This project has no .vscode/launch.json" });
});
it("imports JetBrains run configurations the same way", async () => {
ipc.runImportSources.mockImplementation(async () => ok(["vscode"]));
await act(() => executeAction("run.importJetbrains"));
expect(useRun.getState().editorOpen).toBe(false);
expect(notes.at(-1)).toMatchObject({ title: "This project has no JetBrains run configurations (.run, .idea)" });
ipc.runImportSources.mockImplementation(async () => ok(["jetbrains"]));
render(<EditConfigsOverlay />);
await act(() => executeAction("run.importJetbrains"));
expect(await screen.findByRole("treeitem", { name: /^Django/ })).toBeInTheDocument();
expect(ipc.runImportConfigs).toHaveBeenCalledWith("jetbrains", []);
});
});
+35 -2
Переглянути файл
@@ -65,8 +65,33 @@ describe("import summary", () => {
expect(
screen.getByText("Skipped 'Current file': Uses ${file}, which is known only when VS Code starts it"),
).toBeInTheDocument();
expect(screen.getByText("Skipped 'Chrome': The chrome debugger has no equivalent here")).toBeInTheDocument();
expect(screen.getByText("Skipped 'Chrome': Type 'chrome' has no equivalent here")).toBeInTheDocument();
expect(screen.getByText("Skipped 'Unnamed': Not a configuration with a name")).toBeInTheDocument();
cleanup();
const files = importSummary(
result({
imported: [],
skipped: [
{ name: "Server", reason: { kind: "missingFile", file: "manage.py" } },
{ name: ".run/Broken.run.xml", reason: { kind: "invalidFile", detail: "unclosed element" } },
{ name: "Repo", reason: { kind: "pathVariable", variable: "$MAVEN_REPOSITORY$" } },
{ name: "Job", reason: { kind: "severalModules", variable: "$MODULE_DIR$" } },
],
}),
);
render(<>{files.body}</>);
expect(screen.getByText("Skipped 'Server': manage.py isn't in the project")).toBeInTheDocument();
expect(
screen.getByText("Skipped '.run/Broken.run.xml': The file can't be read: unclosed element"),
).toBeInTheDocument();
expect(
screen.getByText("Skipped 'Repo': Uses $MAVEN_REPOSITORY$, a path variable with no value here"),
).toBeInTheDocument();
expect(
screen.getByText("Skipped 'Job': Uses $MODULE_DIR$, and the project has several modules"),
).toBeInTheDocument();
cleanup();
render(<>{s.body}</>);
expect(screen.getByText("Left out of 'Django': justMyCode")).toBeInTheDocument();
expect(screen.getByText("Click OK or Apply to save them.")).toBeInTheDocument();
cleanup();
@@ -88,7 +113,7 @@ describe("import summary", () => {
await setLocale("zh");
expect(importSummary(two).title).toBe("已从 .vscode/launch.json 导入 2 个运行配置");
render(<>{importSummary(result({ imported: [] })).body}</>);
expect(screen.getByText("已跳过 'Chrome':此处没有与 chrome 调试器对应的配置类型")).toBeInTheDocument();
expect(screen.getByText("已跳过 'Chrome':此处没有与类型 'chrome' 对应的配置")).toBeInTheDocument();
cleanup();
});
});
@@ -153,6 +178,14 @@ describe("Run/Debug Configurations › Import from .vscode/launch.json", () => {
expect(screen.queryByRole("treeitem", { name: /^Django/ })).not.toBeInTheDocument();
});
it("offers one link per source with files", async () => {
ipc.runImportSources.mockImplementation(async () => ok(["vscode", "jetbrains"]));
render(<EditConfigsOverlay />);
expect(await screen.findByRole("button", { name: "Import from .vscode/launch.json" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Import from JetBrains run configurations" }));
await waitFor(() => expect(ipc.runImportConfigs).toHaveBeenCalledWith("jetbrains", [detected]));
});
it("imports again when asked while the dialog is open", async () => {
render(<EditConfigsOverlay />);
await screen.findByRole("button", { name: "Import from .vscode/launch.json" });
+37 -4
Переглянути файл
@@ -1,28 +1,53 @@
/**
* "Import from .vscode/launch.json" in Run/Debug Configurations: the backend maps the file's
* entries (`ide-run` `import`), the configurations join the dialog's draft and are saved with
* it on OK / Apply. A notification says how many came in and why the others were left out.
* "Import from .vscode/launch.json" / "Import from JetBrains run configurations" in Run/Debug
* Configurations and Find Action: the backend maps the files' entries (`ide-run` `import`), the
* configurations join the dialog's draft and are saved with it on OK / Apply. A notification says
* how many came in and why the others were left out.
*/
import type { ReactNode } from "react";
import { tx } from "@/platform/i18n";
import { call, commands, type RunConfig, type RunImport, type RunImportSource, type SkipReason } from "@/platform/ipc";
import { notifications } from "@/workbench/contributions";
import { loadConfigs, useRun } from "./store";
/** The dialog's link for `source`. */
export function importLabel(source: RunImportSource): string {
switch (source) {
case "vscode":
return tx("Import from .vscode/launch.json");
case "jetbrains":
return tx("Import from JetBrains run configurations");
}
}
/** Why Find Action can't import from `source`. */
function missingText(source: RunImportSource): string {
switch (source) {
case "vscode":
return tx("This project has no .vscode/launch.json");
case "jetbrains":
return tx("This project has no JetBrains run configurations (.run, .idea)");
}
}
/** Find Action's "Import Run Configurations from…": opens Run/Debug Configurations, which imports. */
export async function openImport(source: RunImportSource): Promise<void> {
const sources = await call(commands.runImportSources()).catch((): RunImportSource[] => []);
if (!sources.includes(source)) {
notifications().notify({ group: "run", level: "info", title: missingText(source), key: "run-import" });
return;
}
if (!useRun.getState().loaded) await loadConfigs();
useRun.setState({ editorOpen: true, chooserOpen: false, editTarget: null, editImport: source });
}
/** Why an entry was not imported. */
export function skipReasonText(reason: SkipReason): string {
switch (reason.kind) {
case "nameTaken":
return tx("A configuration with this name already exists");
case "unsupportedType":
return tx`The ${reason.type} debugger has no equivalent here`;
return tx`Type '${reason.type}' has no equivalent here`;
case "unsupportedRequest":
return tx`Request '${reason.request}' isn't supported`;
case "unsupportedAttach":
@@ -31,6 +56,14 @@ export function skipReasonText(reason: SkipReason): string {
return tx`Uses ${reason.variable}, which is known only when VS Code starts it`;
case "nothingToRun":
return tx("No program, module or command to run");
case "pathVariable":
return tx`Uses ${reason.variable}, a path variable with no value here`;
case "severalModules":
return tx`Uses ${reason.variable}, and the project has several modules`;
case "missingFile":
return tx`${reason.file} isn't in the project`;
case "invalidFile":
return tx`The file can't be read: ${reason.detail}`;
case "missingMember":
return tx`Its configuration '${reason.member}' isn't imported`;
case "invalid":
+11 -15
Переглянути файл
@@ -5,7 +5,7 @@
import { registerEditorContribution } from "@/editor/api";
import { executeAction, getAction, registerActions } from "@/platform/actions";
import { getContext, setContext } from "@/platform/context";
import { call, commands, onIdeEvent, type RunConfig, type RunImportSource } from "@/platform/ipc";
import { onIdeEvent, type RunConfig } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { onWorkflowSettingsChange } from "@/platform/workflowSettings";
import { showContextMenu, lazyComponent, type MenuItem } from "@/ui";
@@ -35,6 +35,7 @@ import { debugWithAgent, jdwpLaunch } from "./javaDebug";
import { RunDialogsOverlay } from "./dialogs";
import { registerExternalTools } from "./externalTools";
import { EditConfigsOverlay } from "./EditConfigsDialog";
import { openImport } from "./importConfigs";
import { registerGoActions } from "./goActions";
import { invalidateGoModules } from "./goModules";
import { RunWidget } from "./RunWidget";
@@ -420,20 +421,15 @@ export function activate(): () => void {
category: "Run",
when: "projectOpen",
keywords: ["launch.json", "vscode", "visual studio code", "debug configuration"],
run: async () => {
const sources = await call(commands.runImportSources()).catch((): RunImportSource[] => []);
if (!sources.includes("vscode")) {
notifications().notify({
group: "run",
level: "info",
title: "This project has no .vscode/launch.json",
key: "run-import",
});
return;
}
await ensureConfigs();
useRun.setState({ editorOpen: true, chooserOpen: false, editTarget: null, editImport: "vscode" });
},
run: () => openImport("vscode"),
},
{
id: "run.importJetbrains",
title: "Import Run Configurations from JetBrains (.run, .idea)",
category: "Run",
when: "projectOpen",
keywords: ["pycharm", "webstorm", "intellij", "run.xml", "workspace.xml", "run configuration"],
run: () => openImport("jetbrains"),
},
{
id: "run.debugConfig",
+8 -1
Переглянути файл
@@ -2960,7 +2960,7 @@
"Left out of '{name}': {keys}": "'{name}' 中未导入的设置:{keys}",
"Click OK or Apply to save them.": "点击“确定”或“应用”以保存。",
"A configuration with this name already exists": "已存在同名配置",
"The {type} debugger has no equivalent here": "此处没有与 {type} 调试器对应的配置类型",
"Type '{type}' has no equivalent here": "此处没有与类型 '{type}' 对应的配置",
"Request '{request}' isn't supported": "不支持请求 '{request}'",
"Only attaching to a host and port is supported": "仅支持附加到主机和端口",
"Uses {variable}, which is known only when VS Code starts it": "使用了 {variable},其值只有在 VS Code 启动时才能确定",
@@ -2969,6 +2969,13 @@
"Not a configuration with a name": "不是带名称的配置",
"'{name}' is saved in run.local.toml because it uses values from this machine": "'{name}' 使用了本机的值,因此保存在 run.local.toml 中",
"This project has no .vscode/launch.json": "此项目没有 .vscode/launch.json",
"Import from JetBrains run configurations": "从 JetBrains 运行配置导入",
"Import Run Configurations from JetBrains (.run, .idea)": "从 JetBrains 导入运行配置(.run、.idea)",
"This project has no JetBrains run configurations (.run, .idea)": "此项目没有 JetBrains 运行配置(.run、.idea)",
"{file} isn't in the project": "项目中没有 {file}",
"Uses {variable}, a path variable with no value here": "使用了 {variable},此处没有该路径变量的值",
"Uses {variable}, and the project has several modules": "使用了 {variable},但项目有多个模块",
"The file can't be read: {detail}": "无法读取该文件:{detail}",
"Templates": "模板",
"Configuration templates": "配置模板",
"Back to configurations": "返回配置",