дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
df6fbb6d21
коміт
9a6b84e287
11 files changed
+1832
-143
No files matched your search
@@ -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:")
|
||||
}
|
||||
|
||||
|
||||
@@ -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 & b\">\
|
||||
<option name=\"SCRIPT_TEXT\" value=\"echo 1 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\)");
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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=""not slow"" />
|
||||
<option name="_new_parameters" value="""" />
|
||||
<option name="_new_additionalArguments" value=""-x --tb=short"" />
|
||||
<option name="_new_target" value=""$PROJECT_DIR$/tests/test_orders.py"" />
|
||||
<option name="_new_targetType" value=""PATH"" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration name="Case" type="tests" factoryName="py.test">
|
||||
{PY_COMMON}
|
||||
<option name="_new_parameters" value=""eur-2"" />
|
||||
<option name="_new_target" value=""tests.test_money.test_round"" />
|
||||
<option name="_new_targetType" value=""PYTHON"" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration name="Path params" type="tests" factoryName="py.test">
|
||||
{PY_COMMON}
|
||||
<option name="_new_keywords" value=""'slow or fast'"" />
|
||||
<option name="_new_parameters" value=""eur-2"" />
|
||||
<option name="_new_target" value=""$PROJECT_DIR$/tests/test_money.py"" />
|
||||
<option name="_new_targetType" value=""PATH"" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration name="Custom" type="tests" factoryName="py.test">
|
||||
{PY_COMMON}
|
||||
<option name="_new_keywords" value=""slow"" />
|
||||
<option name="_new_additionalArguments" value=""tests/a.py --maxfail=2"" />
|
||||
<option name="_new_target" value="""" />
|
||||
<option name="_new_targetType" value=""CUSTOM"" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration name="Unittests in tests" type="tests" factoryName="Unittests">
|
||||
{PY_COMMON}
|
||||
<option name="_new_target" value=""tests"" />
|
||||
<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 && npm run dev && 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=""{params}"" />
|
||||
<option name="_new_target" value=""{target}"" />
|
||||
<option name="_new_targetType" value=""PYTHON"" />
|
||||
<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. */
|
||||
|
||||
@@ -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", []);
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "返回配置",
|
||||
|
||||
Reference in new issue
Заблокувати користувача