mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 18:38:33 -10:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ec4032cf7 | ||
|
|
a0ac988963 | ||
|
|
d5c4066748 | ||
|
|
8d784ac164 | ||
|
|
7ffaa0b5da | ||
|
|
ab2b53ff80 | ||
|
|
2ba731f0ec | ||
|
|
742e504719 | ||
|
|
ebe059e00f | ||
|
|
e17e59ce7d | ||
|
|
8021a8ccca | ||
|
|
5a6a6f7e05 | ||
|
|
a7f0f8110b | ||
|
|
9669e58398 | ||
|
|
d939dca303 | ||
|
|
23378e0cc2 | ||
|
|
c83264ae75 |
No files matched your search
@@ -2,6 +2,8 @@ name: Language smoke
|
||||
# Everyday code intelligence (completion, go to definition, Find Usages, hover, diagnostics
|
||||
# with a quick fix, an intention, formatting, rename) per language, against the language
|
||||
# server the app starts by default: crates/ide-host/tests/language_smoke.rs.
|
||||
# Then running and testing each fixture through the run store and openidea-server
|
||||
# (Run, Run tests, the test tree, Rerun Failed Tests): smoke/run.test.ts.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -14,12 +16,18 @@ on:
|
||||
- "fixtures/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "src/features/run/**"
|
||||
- "src/platform/**"
|
||||
- "smoke/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- ".github/workflows/language-smoke.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: language-smoke-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
# A new push supersedes a PR's run; on main every merge runs to the end (and saves the cache).
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
@@ -53,9 +61,10 @@ jobs:
|
||||
shared-key: language-smoke
|
||||
save-if: ${{ github.ref == 'refs/heads/main' && matrix.language == 'rust' }}
|
||||
|
||||
- if: matrix.language == 'typescript'
|
||||
uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- uses: pnpm/action-setup@v4
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- if: matrix.language == 'typescript'
|
||||
run: npm install --global "@vtsls/language-server@$VTSLS" "typescript@$TYPESCRIPT"
|
||||
|
||||
@@ -95,3 +104,7 @@ jobs:
|
||||
|
||||
- run: cargo test --locked -p ide-host --test language_smoke --no-run
|
||||
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: cargo build --locked -p ide-host --bin openidea-server
|
||||
- run: pnpm smoke smoke/run.test.ts -t "^${{ matrix.language }}$"
|
||||
@@ -69,6 +69,8 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
|
||||
- `cargo nextest run --workspace` (or `cargo test`), `cargo clippy --workspace --all-targets -- -D warnings`, `cargo fmt --all`.
|
||||
- `cargo test -p ide-host --test language_smoke -- --ignored --exact <language>` — code intelligence
|
||||
on `fixtures/` with that language's real server installed (CI: `Language smoke`, pinned versions).
|
||||
- `pnpm smoke smoke/run.test.ts -t <language>` — run and test that language's fixture through the
|
||||
run store and a real `openidea-server` (build it first: `cargo build -p ide-host --bin openidea-server`).
|
||||
- `pnpm test`, `pnpm e2e`, `pnpm lint`, `pnpm format:check`, `pnpm knip`, `pnpm typecheck`,
|
||||
`pnpm gen:bindings`.
|
||||
- `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion
|
||||
|
||||
@@ -716,6 +716,12 @@ pub struct PhpSettings {
|
||||
/// PHP version the language server checks the code against (`7.4`); empty = from
|
||||
/// `composer.json` (`config.platform.php`, else the lowest version `require.php` allows).
|
||||
pub language_level: String,
|
||||
/// Folders outside the project whose code the language server indexes too (a platform a
|
||||
/// module extends); absolute, `~/`, or relative to the project.
|
||||
pub include_paths: Vec<String>,
|
||||
/// Intelephense stub sets loaded on top of its defaults and the `ext-*` entries of
|
||||
/// `composer.json` (`wordpress`).
|
||||
pub stubs: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -589,11 +589,12 @@ impl DebugManager {
|
||||
if let Some(command) = custom_command(&spec.command) {
|
||||
spec.command = command;
|
||||
}
|
||||
// debugpy: run the adapter with the debuggee's interpreter (venvs carry their own debugpy).
|
||||
// debugpy: run the adapter with the debuggee's interpreter (venvs carry their own debugpy),
|
||||
// or with the launcher's when the debuggee runs through a manager (`uv run python`).
|
||||
if config.adapter == "debugpy" {
|
||||
let python = match arguments.get("python") {
|
||||
Some(Value::String(p)) => Some(p.clone()),
|
||||
Some(Value::Array(a)) => a.first().and_then(Value::as_str).map(str::to_owned),
|
||||
let python = match (arguments.get("debugLauncherPython"), arguments.get("python")) {
|
||||
(Some(Value::String(p)), _) | (_, Some(Value::String(p))) => Some(p.clone()),
|
||||
(_, Some(Value::Array(a))) => a.first().and_then(Value::as_str).map(str::to_owned),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(python) = python {
|
||||
@@ -651,8 +652,19 @@ impl DebugManager {
|
||||
{
|
||||
arguments["project"] = json!(real);
|
||||
}
|
||||
if config.adapter == "codelldb" && arguments.get("sourceLanguages").is_none() {
|
||||
arguments["sourceLanguages"] = json!(["rust"]);
|
||||
if config.adapter == "codelldb" {
|
||||
if arguments.get("sourceLanguages").is_none() {
|
||||
arguments["sourceLanguages"] = json!(["rust"]);
|
||||
}
|
||||
// The Debug Console and Evaluate Expression take expressions, as with every other
|
||||
// debugger; LLDB commands still run with a leading backtick.
|
||||
match arguments.get_mut("_adapterSettings") {
|
||||
None => arguments["_adapterSettings"] = json!({"consoleMode": "evaluate"}),
|
||||
Some(Value::Object(settings)) => {
|
||||
settings.entry("consoleMode").or_insert_with(|| json!("evaluate"));
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
let port = debug_server.map(|s| s.port);
|
||||
self.start_session(id, owner, config.name, config.adapter, None, config.request, arguments, spec, port).await
|
||||
|
||||
@@ -698,6 +698,37 @@ async fn js_debug_launch_gets_port_and_workspace_folder() {
|
||||
assert_eq!(launch["type"], "pwa-node");
|
||||
}
|
||||
|
||||
/// CodeLLDB treats console input as LLDB commands unless told otherwise: Evaluate Expression
|
||||
/// and the Debug Console send expressions, so its console evaluates them. A launch
|
||||
/// configuration that picks a console mode keeps it.
|
||||
#[tokio::test]
|
||||
async fn codelldb_console_evaluates_expressions() {
|
||||
let launch_with = |extra: Value| async move {
|
||||
let h = harness(Script::default());
|
||||
let config = LaunchConfig {
|
||||
name: "demo".into(),
|
||||
adapter: "codelldb".into(),
|
||||
request: "launch".into(),
|
||||
program: Some("/p/target/debug/demo".into()),
|
||||
args: vec![],
|
||||
cwd: Some("/p".into()),
|
||||
env: BTreeMap::new(),
|
||||
extra_json: Some(extra.to_string()),
|
||||
};
|
||||
h.start(config).await.unwrap();
|
||||
h.request("launch")["arguments"].clone()
|
||||
};
|
||||
let launch = launch_with(json!({})).await;
|
||||
assert_eq!(launch["_adapterSettings"]["consoleMode"], "evaluate");
|
||||
assert_eq!(launch["sourceLanguages"], json!(["rust"]));
|
||||
|
||||
let launch =
|
||||
launch_with(json!({"_adapterSettings": {"consoleMode": "commands", "showDisassembly": "never"}})).await;
|
||||
assert_eq!(launch["_adapterSettings"], json!({"consoleMode": "commands", "showDisassembly": "never"}));
|
||||
let launch = launch_with(json!({"_adapterSettings": {"showDisassembly": "never"}})).await;
|
||||
assert_eq!(launch["_adapterSettings"], json!({"consoleMode": "evaluate", "showDisassembly": "never"}));
|
||||
}
|
||||
|
||||
/// An adapter that is already listening (java-debug inside jdtls): the client
|
||||
/// connects over TCP to the `debugServer` port instead of spawning anything.
|
||||
#[tokio::test]
|
||||
|
||||
@@ -142,6 +142,21 @@ pub async fn run_gradle_jvm_args_script(_host: &Host, _window: &str, jvm_args: V
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// Writes one of the IDE's Gradle init scripts (`text`; the file is named `<name>-<hash>`) on
|
||||
/// the machine that runs the build, for `--init-script`, and returns its path.
|
||||
pub async fn run_gradle_init_script(_host: &Host, _window: &str, name: String, text: String) -> IdeResult<String> {
|
||||
if name.is_empty() || !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
|
||||
return Err(IdeError::invalid(format!("invalid init script name {name:?}")));
|
||||
}
|
||||
tokio::task::spawn_blocking(move || {
|
||||
ide_run::gradle_jvm::write_script(&ide_run::gradle_jvm::default_dir(), &name, &text)
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.map_err(|e| IdeError::internal(format!("cannot write the Gradle init script: {e}")))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// Results from the JUnit XML reports (Surefire, Failsafe, Gradle) of the module at
|
||||
/// `module` (relative to the project root) written since `since_ms` (Unix epoch, ms).
|
||||
pub async fn run_test_reports(
|
||||
@@ -308,6 +323,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("run_gradle_jvm_args_script", |c| async move {
|
||||
run_gradle_jvm_args_script(&c.host, &c.window, c.arg("jvmArgs")?).await
|
||||
});
|
||||
d.add("run_gradle_init_script", |c| async move {
|
||||
run_gradle_init_script(&c.host, &c.window, c.arg("name")?, c.arg("text")?).await
|
||||
});
|
||||
d.add("run_test_reports", |c| async move {
|
||||
run_test_reports(&c.host, &c.window, c.arg("module")?, c.arg("sinceMs")?).await
|
||||
});
|
||||
|
||||
@@ -34,6 +34,42 @@ pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult<Option<ide_c
|
||||
Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into))
|
||||
}
|
||||
|
||||
/// The PHP language level of the project `path` belongs to (a workspace member, by path, as
|
||||
/// the language server configuration picks it).
|
||||
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PhpLanguageLevels {
|
||||
/// The one Intelephense checks against; `None`: its default.
|
||||
pub effective: Option<ide_sdk::php::PhpLanguageLevel>,
|
||||
/// The one Auto picks, from `composer.json`.
|
||||
pub auto: Option<ide_sdk::php::PhpLanguageLevel>,
|
||||
/// The workspace member `path` belongs to: its own settings choose the level, not the
|
||||
/// workspace's. `None`: the opened project.
|
||||
pub member: Option<ide_core::IpcPath>,
|
||||
}
|
||||
|
||||
pub async fn sdk_php_language_level(host: &Host, window: &str, path: PathBuf) -> IdeResult<PhpLanguageLevels> {
|
||||
let project = host.project(window)?;
|
||||
let (project_root, project_settings) = (project.root.clone(), project.settings.effective());
|
||||
let config_dir = host.config_dir.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let member = ide_workspace::workspace::Workspace::load(&project_root).ok().flatten().and_then(|w| {
|
||||
w.projects.into_iter().filter(|p| path.starts_with(p)).max_by_key(|p| p.components().count())
|
||||
});
|
||||
let (root, settings) = match &member {
|
||||
Some(member) => (member, ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective()),
|
||||
None => (&project_root, project_settings),
|
||||
};
|
||||
PhpLanguageLevels {
|
||||
effective: ide_sdk::php::language_level(root, &settings),
|
||||
auto: ide_sdk::php::composer_level(root),
|
||||
member: member.map(ide_core::IpcPath),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(join_err)
|
||||
}
|
||||
|
||||
/// SDK / library source folders shown under "External Libraries" in the Project view,
|
||||
/// with the Maven / Gradle dependency jars of the build model.
|
||||
pub async fn sdk_project_libraries(host: &Host, window: &str) -> IdeResult<Vec<ide_sdk::libraries::LibraryGroup>> {
|
||||
@@ -144,6 +180,10 @@ pub async fn sdk_python_pip(
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("sdk_detect", |c| async move { sdk_detect(&c.host, &c.window, c.arg("kind")?).await });
|
||||
d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window) });
|
||||
d.add(
|
||||
"sdk_php_language_level",
|
||||
|c| async move { sdk_php_language_level(&c.host, &c.window, c.arg("path")?).await },
|
||||
);
|
||||
d.add("sdk_project_libraries", |c| async move { sdk_project_libraries(&c.host, &c.window).await });
|
||||
d.add("sdk_locate_tools", |c| async move {
|
||||
sdk_locate_tools(&c.host, &c.window, c.arg("names")?, c.arg("near")?).await
|
||||
|
||||
@@ -667,6 +667,17 @@ impl HostApi for PluginApi {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_location(&self, client: Option<&str>, path: &Path, line: u32, column: u32) -> Result<(), HostError> {
|
||||
let event = PluginEvent::OpenLocation {
|
||||
plugin_id: self.id().to_owned(),
|
||||
path: path.to_path_buf().into(),
|
||||
line,
|
||||
column,
|
||||
};
|
||||
self.shared.sink.emit(client, event);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn setting(&self, root: Option<&Path>, key: &str) -> Option<String> {
|
||||
let schema: Vec<super::ui_points::SettingSchema> = self
|
||||
.shared
|
||||
|
||||
@@ -114,6 +114,7 @@ pub fn forwarded(event: &PluginEvent) -> bool {
|
||||
| PluginEvent::ViewChanged { .. }
|
||||
| PluginEvent::ViewMessage { .. }
|
||||
| PluginEvent::OpenTerminal { .. }
|
||||
| PluginEvent::OpenLocation { .. }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -192,6 +192,8 @@ pub enum PluginEvent {
|
||||
args: Vec<String>,
|
||||
cwd: ide_core::IpcPath,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
OpenLocation { plugin_id: String, path: ide_core::IpcPath, line: u32, column: u32 },
|
||||
/// Plugin languages, or the servers they may start, changed: re-fetch
|
||||
/// `languages` (the host re-detects open documents: [`crate::Host::plugin_languages_changed`]).
|
||||
LanguagesChanged,
|
||||
|
||||
@@ -378,9 +378,19 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
}
|
||||
let mut out = UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?);
|
||||
out.files = files;
|
||||
if let Some(icon) = d.icon.as_deref().filter(|i| i.ends_with(".svg")) {
|
||||
let path = std::path::PathBuf::from(icon);
|
||||
let svg = super::icons::read_icon(pkg, &path)?;
|
||||
out.assets.push((view_icon_asset(&d.id), svg));
|
||||
out.files.push(path);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn view_icon_asset(id: &str) -> String {
|
||||
format!("viewIcon/{id}")
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CustomEditorDecl {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Language smoke tests: the everyday code intelligence of each language — completion, go to
|
||||
//! definition, Find Usages, hover, diagnostics with a quick fix, an intention, rename and
|
||||
//! formatting — on its `fixtures/` project, through the `ops` the app's commands call and the
|
||||
//! definition, Find Usages, hover, diagnostics with a quick fix, an intention, rename,
|
||||
//! formatting, parameter info, the Structure view, auto-import, Optimize Imports and moving a
|
||||
//! file — on its `fixtures/` project, through the `ops` the app's commands call and the
|
||||
//! language server the app starts by default.
|
||||
//!
|
||||
//! `#[ignore]`d because they need the servers installed. CI's `Language smoke` job installs
|
||||
@@ -68,6 +69,35 @@ struct Checks {
|
||||
indent: Indent,
|
||||
/// Renaming the symbol to the new name edits these files.
|
||||
rename: (At, &'static str, &'static [&'static str]),
|
||||
/// Parameter info in a call being typed: a signature naming this parameter.
|
||||
signature: (Typed, &'static str),
|
||||
/// The Structure view of a file: these symbols, at any depth.
|
||||
outline: (&'static str, &'static [&'static str]),
|
||||
/// Completing a name the file doesn't import yet also imports it.
|
||||
auto_import: AutoImport,
|
||||
/// Optimize Imports, with an import typed after a line.
|
||||
organize_imports: Option<(Typed, Imports)>,
|
||||
/// Moving a file updates its references: (file, new path, a file that then contains this).
|
||||
file_move: Option<(&'static str, &'static str, &'static str, &'static str)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct AutoImport {
|
||||
/// The line typed, ending in the start of the name.
|
||||
typed: Typed,
|
||||
/// The completion item.
|
||||
label: &'static str,
|
||||
/// What the item's import adds to the file.
|
||||
result: &'static str,
|
||||
}
|
||||
|
||||
/// What Optimize Imports does with the typed import.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Imports {
|
||||
/// Removes it (unused).
|
||||
Removed,
|
||||
/// Sorts it before this line.
|
||||
Before(&'static str),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -432,6 +462,146 @@ impl Smoke {
|
||||
}
|
||||
}
|
||||
|
||||
async fn signature(&self, call: Typed, parameter: &str) {
|
||||
let (doc, pos, before) = self.type_line(call).await;
|
||||
self.retry("parameter info", self.warm_up, || async {
|
||||
let found = ops::lsp::lsp_signature_help(&self.host, WINDOW, doc, pos).await;
|
||||
match &found {
|
||||
Ok(Some(h)) if h.signatures.iter().any(|s| s.label.contains(parameter)) => Ok(()),
|
||||
_ => Err(found.map(|h| h.map(|h| h.signatures.into_iter().map(|s| s.label).collect::<Vec<_>>()))),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
self.restore(doc, &before).await;
|
||||
}
|
||||
|
||||
async fn outline(&self, file: &str, symbols: &[&str]) {
|
||||
let (doc, _) = self.doc(file).await;
|
||||
fn names(items: &[ide_core::OutlineItem], out: &mut BTreeSet<String>) {
|
||||
for item in items {
|
||||
out.insert(item.name.clone());
|
||||
names(&item.children, out);
|
||||
}
|
||||
}
|
||||
self.retry("structure", self.warm_up, || async {
|
||||
let found = ops::doc::doc_outline(&self.host, WINDOW, doc).await;
|
||||
let mut shown = BTreeSet::new();
|
||||
if let Ok(items) = &found {
|
||||
names(items, &mut shown);
|
||||
}
|
||||
if symbols.iter().all(|s| shown.contains(*s)) { Ok(()) } else { Err(shown) }
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn auto_import(&self, a: AutoImport) {
|
||||
let (doc, pos, before) = self.type_line(a.typed).await;
|
||||
assert!(!before.contains(a.result), "{a:?}: the file already contains the result");
|
||||
self.retry("auto-import completion", self.warm_up, || async {
|
||||
let list = ops::lsp::lsp_completion(&self.host, WINDOW, RequestId(6), doc, pos, None).await;
|
||||
let items = match &list {
|
||||
Ok(l) => l.items.iter().filter(|i| i.label == a.label).cloned().collect::<Vec<_>>(),
|
||||
Err(e) => return Err(format!("{e}")),
|
||||
};
|
||||
let text = self.doc(a.typed.file).await.1;
|
||||
let mut imports = Vec::new();
|
||||
for item in items {
|
||||
// Servers compute the import when the item is resolved (on selection).
|
||||
let item = if item.additional_edits.is_empty() {
|
||||
ops::lsp::lsp_resolve_completion(&self.host, WINDOW, doc, item.clone()).await.unwrap_or(item)
|
||||
} else {
|
||||
item
|
||||
};
|
||||
let mut document = ide_text::Document::new(&text);
|
||||
if document.apply_edits(&item.additional_edits).is_ok() && document.text().contains(a.result) {
|
||||
return Ok(());
|
||||
}
|
||||
// Or accepting it runs a server command that edits the file (kotlin-lsp).
|
||||
if let Some(command) = item.command.clone() {
|
||||
let ran = ops::lsp::lsp_execute_command(&self.host, WINDOW, doc, command).await;
|
||||
let text = self.doc(a.typed.file).await.1;
|
||||
if ran.is_ok() && text.contains(a.result) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
imports.push(format!("{:?} {:?}", item.additional_edits, item.command));
|
||||
}
|
||||
let labels: Vec<String> = list.map(|l| l.items.into_iter().take(20).map(|i| i.label).collect()).unwrap();
|
||||
Err(format!("{:?} items' imports: {imports:?}; items: {labels:?}", a.label))
|
||||
})
|
||||
.await;
|
||||
self.restore(doc, &before).await;
|
||||
}
|
||||
|
||||
async fn organize_imports(&self, import: Typed, expect: Imports) {
|
||||
let (doc, _, before) = self.type_line(import).await;
|
||||
let typed = import.text.trim();
|
||||
let organized = |text: &str| match expect {
|
||||
Imports::Removed => !text.lines().any(|l| l.trim() == typed),
|
||||
Imports::Before(line) => {
|
||||
let at = |needle: &str| text.lines().position(|l| l.trim() == needle);
|
||||
matches!((at(typed), at(line)), (Some(a), Some(b)) if a < b)
|
||||
}
|
||||
};
|
||||
self.retry("optimize imports", self.warm_up, || async {
|
||||
let text = self.doc(import.file).await.1;
|
||||
// The whole file, as Optimize Imports asks (positions past the end fail on jdtls).
|
||||
let last = text.split('\n').count() as u32 - 1;
|
||||
let whole = Range::new(Pos::new(0, 0), Pos::new(last, utf16_len(text.rsplit('\n').next().unwrap_or(""))));
|
||||
let kind = "source.organizeImports".to_string();
|
||||
let found = ops::lsp::lsp_code_actions(&self.host, WINDOW, doc, whole, vec![kind.clone()]).await;
|
||||
let actions: Vec<_> = match &found {
|
||||
Ok(a) => a.iter().filter(|a| a.kind.as_ref().is_some_and(|k| k.starts_with(&kind))).cloned().collect(),
|
||||
Err(e) => return Err(format!("{e}")),
|
||||
};
|
||||
let Some(action) = actions.iter().find(|a| a.is_preferred).or(actions.first()).cloned() else {
|
||||
return Err(format!(
|
||||
"no {kind} action: {:?}",
|
||||
found.map(|a| a.into_iter().map(|a| a.title).collect::<Vec<_>>())
|
||||
));
|
||||
};
|
||||
ops::lsp::lsp_apply_code_action(&self.host, WINDOW, doc, action).await.map_err(|e| e.to_string())?;
|
||||
let text = self.doc(import.file).await.1;
|
||||
if organized(&text) { Ok(()) } else { Err(text) }
|
||||
})
|
||||
.await;
|
||||
self.restore(doc, &before).await;
|
||||
}
|
||||
|
||||
async fn file_move(&self, from: &str, to: &str, referencing: &str, result: &str) {
|
||||
// The servers know the referencing file (opened, as in an editor).
|
||||
let _ = self.doc(referencing).await;
|
||||
let moves = vec![ide_workspace::FileMove { from: self.path(from).into(), to: self.path(to).into() }];
|
||||
let edit = self
|
||||
.retry("move file", self.warm_up, || async {
|
||||
let found =
|
||||
ops::refactor::refactor_file_move_edits(&self.host, WINDOW, RequestId(7), moves.clone()).await;
|
||||
let want = self.path(referencing);
|
||||
match &found {
|
||||
Ok(e)
|
||||
if e.edit
|
||||
.files
|
||||
.iter()
|
||||
.any(|f| matches!(f, ide_core::FileEdit::Edit { path, .. } if **path == *want)) =>
|
||||
{
|
||||
Ok(e.edit.clone())
|
||||
}
|
||||
_ => Err(found),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
ops::refactor::refactor_move_files(&self.host, WINDOW, moves, edit, "Move".into())
|
||||
.await
|
||||
.unwrap_or_else(|e| self.fail("move file", e));
|
||||
if !self.path(to).is_file() {
|
||||
self.fail("move file", format_args!("{to} doesn't exist after the move"));
|
||||
}
|
||||
let text = self.doc(referencing).await.1;
|
||||
if !text.contains(result) {
|
||||
self.fail("move file", format_args!("{referencing} has no {result:?} after the move:\n{text}"));
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(&self, c: &Checks) {
|
||||
self.definition(c.definition.0, c.definition.1).await;
|
||||
self.references(c.references.0, c.references.1).await;
|
||||
@@ -440,7 +610,17 @@ impl Smoke {
|
||||
self.quick_fix(c.quick_fix).await;
|
||||
self.intention(c.intention.0, c.intention.1).await;
|
||||
self.format(c.format.0, c.indent, c.format.1).await;
|
||||
self.signature(c.signature.0, c.signature.1).await;
|
||||
self.outline(c.outline.0, c.outline.1).await;
|
||||
self.auto_import(c.auto_import).await;
|
||||
if let Some((import, expect)) = c.organize_imports {
|
||||
self.organize_imports(import, expect).await;
|
||||
}
|
||||
self.rename(c.rename.0, c.rename.1, c.rename.2).await;
|
||||
// Last: it moves a file the other checks use.
|
||||
if let Some((from, to, referencing, result)) = c.file_move {
|
||||
self.file_move(from, to, referencing, result).await;
|
||||
}
|
||||
let shutdown = self.host.project(WINDOW).unwrap().lsp.clone();
|
||||
let _ = tokio::time::timeout(Duration::from_secs(20), shutdown.shutdown_all()).await;
|
||||
}
|
||||
@@ -480,6 +660,22 @@ const TYPESCRIPT: Checks = Checks {
|
||||
"formatPrice",
|
||||
&["src/index.ts", "src/money.ts"],
|
||||
),
|
||||
signature: (typed("src/index.ts", "const total = cart.total();", " cart.add("), "product"),
|
||||
outline: ("src/cart.ts", &["Cart", "add", "total"]),
|
||||
auto_import: AutoImport {
|
||||
typed: typed("src/index.ts", "const total = cart.total();", " addMon"),
|
||||
label: "addMoney",
|
||||
result: "addMoney",
|
||||
},
|
||||
organize_imports: Some((
|
||||
typed(
|
||||
"src/index.ts",
|
||||
"import { cents, formatMoney } from \"./money.ts\";",
|
||||
"import { readFileSync } from \"node:fs\";",
|
||||
),
|
||||
Imports::Removed,
|
||||
)),
|
||||
file_move: Some(("src/money.ts", "src/lib/money.ts", "src/index.ts", "\"./lib/money.ts\"")),
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
@@ -521,6 +717,19 @@ const PYTHON: Checks = Checks {
|
||||
"TaskBoard",
|
||||
&["src/tasks/cli.py", "src/tasks/models.py"],
|
||||
),
|
||||
signature: (typed("src/tasks/cli.py", "tasks = build_demo()", " tasks.add("), "title"),
|
||||
outline: ("src/tasks/models.py", &["Priority", "Task", "TaskList", "pending"]),
|
||||
auto_import: AutoImport {
|
||||
typed: typed("src/tasks/cli.py", "tasks = build_demo()", " stamp = datetim"),
|
||||
label: "datetime",
|
||||
result: "import datetime",
|
||||
},
|
||||
organize_imports: Some((
|
||||
typed("src/tasks/models.py", "from enum import IntEnum", "import abc"),
|
||||
Imports::Before("import os"),
|
||||
)),
|
||||
// basedpyright has no `workspace/willRenameFiles`.
|
||||
file_move: None,
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
@@ -558,6 +767,16 @@ const RUST: Checks = Checks {
|
||||
),
|
||||
indent: Indent::Spaces(4),
|
||||
rename: (at("src/parser.rs", "pub fn parse(text: &str)", "parse"), "parse_all", &["src/main.rs", "src/parser.rs"]),
|
||||
signature: (typed("src/main.rs", "let mut inventory = Inventory::new();", " inventory.take("), "sku"),
|
||||
outline: ("src/lib.rs", &["Inventory", "Item", "take"]),
|
||||
auto_import: AutoImport {
|
||||
typed: typed("src/main.rs", "let mut inventory = Inventory::new();", " let _seen = HashSe"),
|
||||
label: "HashSet",
|
||||
result: "use std::collections::HashSet;",
|
||||
},
|
||||
// rust-analyzer has no `source.organizeImports`.
|
||||
organize_imports: None,
|
||||
file_move: Some(("src/parser.rs", "src/csv.rs", "src/lib.rs", "pub mod csv;")),
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
@@ -597,6 +816,16 @@ const GO: Checks = Checks {
|
||||
"Open",
|
||||
&["cmd/server/main.go", "internal/store/store.go"],
|
||||
),
|
||||
signature: (typed("cmd/server/main.go", "s := store.New()", "\ts.Create("), "title"),
|
||||
outline: ("internal/store/store.go", &["Store", "New", "(*Store).Create"]),
|
||||
auto_import: AutoImport {
|
||||
typed: typed("cmd/server/main.go", "s := store.New()", "\t_ = strings.ToUpp"),
|
||||
label: "ToUpper",
|
||||
result: "\t\"strings\"",
|
||||
},
|
||||
organize_imports: Some((typed("cmd/server/main.go", "\"strconv\"", "\t\"sort\""), Imports::Removed)),
|
||||
// gopls renames packages, not files: moving a Go file changes no references.
|
||||
file_move: None,
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
@@ -653,6 +882,37 @@ const JAVA: Checks = Checks {
|
||||
"submit",
|
||||
&["src/main/java/com/example/orders/App.java", "src/main/java/com/example/orders/OrderBook.java"],
|
||||
),
|
||||
signature: (
|
||||
typed(
|
||||
"src/main/java/com/example/orders/App.java",
|
||||
"long revenue = book.revenueCents();",
|
||||
" book.place(",
|
||||
),
|
||||
"customer",
|
||||
),
|
||||
outline: (
|
||||
"src/main/java/com/example/orders/OrderBook.java",
|
||||
&["OrderBook", "place(String, String, int, long)", "revenueCents()"],
|
||||
),
|
||||
auto_import: AutoImport {
|
||||
typed: typed(
|
||||
"src/main/java/com/example/orders/App.java",
|
||||
"long revenue = book.revenueCents();",
|
||||
" ArrayLis",
|
||||
),
|
||||
label: "ArrayList",
|
||||
result: "import java.util.ArrayList;",
|
||||
},
|
||||
organize_imports: Some((
|
||||
typed("src/main/java/com/example/orders/OrderBook.java", "import java.util.Optional;", "import java.util.Map;"),
|
||||
Imports::Removed,
|
||||
)),
|
||||
file_move: Some((
|
||||
"src/main/java/com/example/orders/OrderBook.java",
|
||||
"src/main/java/com/example/orders/Ledger.java",
|
||||
"src/main/java/com/example/orders/App.java",
|
||||
"Ledger book",
|
||||
)),
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
@@ -705,6 +965,26 @@ const KOTLIN: Checks = Checks {
|
||||
"find",
|
||||
&["src/main/kotlin/com/example/library/App.kt", "src/main/kotlin/com/example/library/Library.kt"],
|
||||
),
|
||||
signature: (
|
||||
typed("src/main/kotlin/com/example/library/App.kt", "val library = Library()", " library.lend("),
|
||||
"isbn",
|
||||
),
|
||||
outline: ("src/main/kotlin/com/example/library/Library.kt", &["Library", "lend", "search"]),
|
||||
auto_import: AutoImport {
|
||||
typed: typed(
|
||||
"src/main/kotlin/com/example/library/App.kt",
|
||||
"val library = Library()",
|
||||
" val stamp = LocalDateTi",
|
||||
),
|
||||
label: "LocalDateTime",
|
||||
result: "import java.time.LocalDateTime",
|
||||
},
|
||||
organize_imports: Some((
|
||||
typed("src/main/kotlin/com/example/library/App.kt", "import java.time.LocalDate", "import java.util.UUID"),
|
||||
Imports::Removed,
|
||||
)),
|
||||
// kotlin-lsp has no `workspace/willRenameFiles`.
|
||||
file_move: None,
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
|
||||
@@ -2045,6 +2045,83 @@ fn pinned_programs_fill_views_and_open_terminals() {
|
||||
assert_eq!(args, ["follow", "build", "--lines=5"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn views_open_project_files_at_a_line() {
|
||||
let f = fixture(Limits::default());
|
||||
let info = f.install(&sample("jobs"));
|
||||
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
|
||||
std::fs::write(f.project.join("jobs.log"), "build\ndeploy\nfailed\n").unwrap();
|
||||
let press = |button: &str| {
|
||||
f.take_events();
|
||||
let result = f.host.view_event("jobs", "jobs", ide_plugin::ViewEvent::Button { id: button.into() }, f.inv());
|
||||
let opened = f.take_events().into_iter().find_map(|(client, e)| match e {
|
||||
PluginEvent::OpenLocation { path, line, column, .. } => Some((client, path.0, line, column)),
|
||||
_ => None,
|
||||
});
|
||||
(result, opened)
|
||||
};
|
||||
|
||||
let (result, opened) = press("log");
|
||||
assert!(result.is_ok(), "{result:?}");
|
||||
let (client, path, line, column) = opened.expect("a location");
|
||||
assert_eq!((client.as_deref(), line, column), (Some("main"), 2, 4));
|
||||
assert_eq!(path.canonicalize().unwrap(), f.project.join("jobs.log").canonicalize().unwrap());
|
||||
|
||||
let (result, opened) = press("outside");
|
||||
assert!(result.is_err(), "a path outside the project is refused");
|
||||
assert!(opened.is_none());
|
||||
|
||||
f.host.set_capability("jobs", "project.read", false).unwrap();
|
||||
let (result, opened) = press("log");
|
||||
assert!(result.unwrap_err().message.contains("project.read"), "needs project.read");
|
||||
assert!(opened.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn views_can_use_an_svg_icon_from_the_package() {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let clean = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" fill="#08f"/></svg>"##;
|
||||
let package = |dir: &str, icon: &str, svg: &str| {
|
||||
let pkg = tmp.path().join(dir);
|
||||
for (path, text) in [("panel/index.html", "<p>panel</p>"), ("icons/panel.svg", svg)] {
|
||||
std::fs::create_dir_all(pkg.join(path).parent().unwrap()).unwrap();
|
||||
std::fs::write(pkg.join(path), text).unwrap();
|
||||
}
|
||||
std::fs::write(
|
||||
pkg.join("plugin.toml"),
|
||||
format!(
|
||||
r#"id = "panel-icon"
|
||||
name = "Panel Icon"
|
||||
version = "1.0.0"
|
||||
engines.openidea = "^1.0"
|
||||
|
||||
[[contributes.views]]
|
||||
id = "panel"
|
||||
title = "Panel"
|
||||
icon = "{icon}"
|
||||
location = "bottom"
|
||||
web = "panel/index.html"
|
||||
"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
pkg
|
||||
};
|
||||
|
||||
assert!(f.host.install_from_dir(&package("missing", "icons/missing.svg", clean), &Consent::all()).is_err());
|
||||
let scripted = clean.replace("<rect", "<script>alert(1)</script><rect");
|
||||
assert!(f.host.install_from_dir(&package("scripted", "icons/panel.svg", &scripted), &Consent::all()).is_err());
|
||||
|
||||
let info = f.install(&package("clean", "icons/panel.svg", clean));
|
||||
assert!(info.error.is_none(), "{:?}", info.error);
|
||||
let view = f.host.ui_items("views").into_iter().find(|v| v.key == "panel").expect("the view");
|
||||
assert_eq!(view.decl["icon"].as_str(), Some("icons/panel.svg"));
|
||||
let assets = f.host.assets("panel-icon", "viewIcon/panel").unwrap();
|
||||
assert_eq!(assets.len(), 1);
|
||||
assert!(assets[0].1.contains("<rect"));
|
||||
}
|
||||
|
||||
/// A plugin with web views and no code: its pages and their files are
|
||||
/// installed with it and served only from the declared directories, while
|
||||
/// it is enabled and only for the installed version; file icons and
|
||||
|
||||
@@ -141,3 +141,19 @@ async fn workspace_cmake_before_launch_tasks_resolve_in_the_member() {
|
||||
assert!(configs.iter().any(|c| c.id == task.target && c.cwd == build.cwd));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn gradle_init_scripts_are_written_by_the_host_that_runs_the_build() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = host(tmp.path());
|
||||
let text = "allprojects { }\n".to_string();
|
||||
let path =
|
||||
ops::run::run_gradle_init_script(&host, "main", "openidea-gradle-tests".into(), text.clone()).await.unwrap();
|
||||
assert!(path.contains("openidea-gradle-tests-"), "{path}");
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), text);
|
||||
let again = ops::run::run_gradle_init_script(&host, "main", "openidea-gradle-tests".into(), text).await.unwrap();
|
||||
assert_eq!(again, path);
|
||||
// A name is not a path.
|
||||
let escaped = ops::run::run_gradle_init_script(&host, "main", "../escape".into(), String::new()).await;
|
||||
assert!(escaped.is_err(), "{escaped:?}");
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
//! A day with Git on a `fixtures/` project, through the `ops` the app's commands call: an edit
|
||||
//! marked in the gutter, Local Changes and its diff, a commit in the log, a branch, a stash, and
|
||||
//! a merge that conflicts, resolved and committed. Open documents follow every step.
|
||||
|
||||
use ide_core::DocId;
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use ide_vcs::{ChangeKind, CommitOptions, FileStatus, HunkKind, LogQuery};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
const WINDOW: &str = "main";
|
||||
/// The line every branch changes, in `src/lib.rs`.
|
||||
const LINE: &str = "pub fn format_price(cents: u64) -> String {";
|
||||
|
||||
fn git(root: &Path, args: &[&str]) -> String {
|
||||
let out = Command::new("git").current_dir(root).args(args).output().unwrap();
|
||||
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
String::from_utf8(out.stdout).unwrap()
|
||||
}
|
||||
|
||||
fn copy_dir(from: &Path, to: &Path) {
|
||||
std::fs::create_dir_all(to).unwrap();
|
||||
for entry in std::fs::read_dir(from).unwrap().flatten() {
|
||||
let (src, dst) = (entry.path(), to.join(entry.file_name()));
|
||||
if entry.file_type().unwrap().is_dir() {
|
||||
copy_dir(&src, &dst);
|
||||
} else {
|
||||
std::fs::copy(&src, &dst).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Flow {
|
||||
host: Arc<Host>,
|
||||
root: PathBuf,
|
||||
_tmp: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl Flow {
|
||||
async fn new(fixture: &str) -> Self {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let root = base.join(fixture);
|
||||
copy_dir(&Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures").join(fixture), &root);
|
||||
git(&root, &["init", "-q", "-b", "main"]);
|
||||
for (key, value) in [
|
||||
("user.name", "Test"),
|
||||
("user.email", "[email protected]"),
|
||||
("commit.gpgsign", "false"),
|
||||
("core.autocrlf", "false"),
|
||||
] {
|
||||
git(&root, &["config", key, value]);
|
||||
}
|
||||
git(&root, &["add", "-A"]);
|
||||
git(&root, &["commit", "-q", "-m", "Initial commit"]);
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(|_| {})),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
let project = ops::project::open(&host, WINDOW, &root).await.unwrap();
|
||||
for _ in 0..500 {
|
||||
if !project.repos().is_empty() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
Flow { host, root, _tmp: tmp }
|
||||
}
|
||||
|
||||
async fn open(&self, file: &str) -> DocId {
|
||||
ops::doc::open(&self.host, WINDOW, self.root.join(file)).await.unwrap().id
|
||||
}
|
||||
|
||||
async fn text(&self, doc: DocId) -> String {
|
||||
ops::doc::snapshot(&self.host, WINDOW, doc).await.unwrap().text
|
||||
}
|
||||
|
||||
/// Types `with` over `LINE` in the editor (unsaved).
|
||||
async fn edit_line(&self, doc: DocId, with: &str) {
|
||||
let text = self.text(doc).await;
|
||||
let current = text.lines().find(|l| l.starts_with("pub fn format_price")).unwrap().to_owned();
|
||||
let project = self.host.project(WINDOW).unwrap();
|
||||
project.documents.replace_all(doc, &text.replacen(¤t, with, 1)).unwrap();
|
||||
}
|
||||
|
||||
async fn save(&self, doc: DocId) {
|
||||
ops::doc::save(&self.host, WINDOW, doc).await.unwrap();
|
||||
}
|
||||
|
||||
async fn status(&self) -> Vec<FileStatus> {
|
||||
ops::vcs::status(&self.host, WINDOW).await.unwrap()
|
||||
}
|
||||
|
||||
async fn commit(&self, files: &[&str], message: &str) -> String {
|
||||
let paths = files.iter().map(|f| self.root.join(f)).collect();
|
||||
ops::vcs::vcs_stage(&self.host, WINDOW, paths).await.unwrap();
|
||||
let options = CommitOptions { run_hooks: true, ..Default::default() };
|
||||
ops::vcs::vcs_commit(&self.host, WINDOW, None, message.into(), options).await.unwrap()
|
||||
}
|
||||
|
||||
async fn head(&self) -> ide_vcs::Commit {
|
||||
let log = ops::vcs::vcs_log(&self.host, WINDOW, None, LogQuery::default()).await.unwrap();
|
||||
log.into_iter().next().expect("a commit")
|
||||
}
|
||||
}
|
||||
|
||||
fn changed<'a>(status: &'a [FileStatus], file: &str) -> Option<&'a FileStatus> {
|
||||
status.iter().find(|s| s.rel_path == file)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn change_commit_branch_stash_and_merge_a_conflict() {
|
||||
let f = Flow::new("rust-app").await;
|
||||
let lib_path = f.root.join("src/lib.rs");
|
||||
let lib = f.open("src/lib.rs").await;
|
||||
let original = f.text(lib).await;
|
||||
let line = original.lines().position(|l| l == LINE).expect("the fixture's line") as u32;
|
||||
|
||||
// An edit in the editor: a modified line in the gutter, against HEAD, before saving.
|
||||
let base = "pub fn format_price(cents: u64) -> String { // base";
|
||||
f.edit_line(lib, base).await;
|
||||
let hunks = ops::vcs::vcs_line_hunks(&f.host, WINDOW, lib_path.clone()).await.unwrap();
|
||||
assert!(
|
||||
matches!(&hunks[..], [h] if h.kind == HunkKind::Modified && h.start == line && h.old_text.contains(LINE)),
|
||||
"{hunks:?}"
|
||||
);
|
||||
assert!(f.status().await.is_empty(), "nothing saved yet");
|
||||
|
||||
// Saved: Local Changes lists it, and its diff shows the edit.
|
||||
f.save(lib).await;
|
||||
let status = f.status().await;
|
||||
assert_eq!(changed(&status, "src/lib.rs").and_then(|s| s.unstaged), Some(ChangeKind::Modified), "{status:?}");
|
||||
let diff = ops::vcs::vcs_diff_text(&f.host, WINDOW, lib_path.clone(), false).await.unwrap();
|
||||
assert!(diff.contains(&format!("-{LINE}")) && diff.contains(&format!("+{base}")), "{diff}");
|
||||
|
||||
// Commit: the log's head; nothing left to commit; no gutter marks.
|
||||
let id = f.commit(&["src/lib.rs"], "Mark the base").await;
|
||||
let head = f.head().await;
|
||||
assert_eq!((head.id.as_str(), head.summary.as_str()), (id.as_str(), "Mark the base"));
|
||||
assert!(f.status().await.is_empty());
|
||||
assert!(ops::vcs::vcs_line_hunks(&f.host, WINDOW, lib_path.clone()).await.unwrap().is_empty());
|
||||
|
||||
// A branch changes the line its way.
|
||||
ops::vcs::vcs_create_branch(&f.host, WINDOW, None, "feature".into(), None, true).await.unwrap();
|
||||
let theirs = "pub fn format_price(cents: u64) -> String { // feature";
|
||||
f.edit_line(lib, theirs).await;
|
||||
f.save(lib).await;
|
||||
f.commit(&["src/lib.rs"], "Feature's change").await;
|
||||
let branches = ops::vcs::vcs_branches(&f.host, WINDOW, None).await.unwrap();
|
||||
assert!(branches.iter().any(|b| b.name == "feature" && b.current), "{branches:?}");
|
||||
|
||||
// Back on main, the open file shows main's text.
|
||||
ops::vcs::vcs_checkout(&f.host, WINDOW, None, "main".into(), false, None).await.unwrap();
|
||||
assert!(f.text(lib).await.contains(base));
|
||||
|
||||
// Stash: work in progress put aside and brought back.
|
||||
let main_rs = f.open("src/main.rs").await;
|
||||
let wip = format!("{}// work in progress\n", f.text(main_rs).await);
|
||||
f.host.project(WINDOW).unwrap().documents.replace_all(main_rs, &wip).unwrap();
|
||||
f.save(main_rs).await;
|
||||
ops::vcs::vcs_stash_push(&f.host, WINDOW, None, Some("wip".into()), false, false).await.unwrap();
|
||||
assert!(f.status().await.is_empty());
|
||||
assert!(!f.text(main_rs).await.contains("work in progress"));
|
||||
let stashes = ops::vcs::vcs_stashes(&f.host, WINDOW, None).await.unwrap();
|
||||
assert!(matches!(&stashes[..], [s] if s.message.contains("wip")), "{stashes:?}");
|
||||
ops::vcs::vcs_stash_apply(&f.host, WINDOW, None, 0, true, false, None).await.unwrap();
|
||||
assert_eq!(f.text(main_rs).await, wip);
|
||||
assert!(ops::vcs::vcs_stashes(&f.host, WINDOW, None).await.unwrap().is_empty());
|
||||
ops::vcs::vcs_discard(&f.host, WINDOW, vec![f.root.join("src/main.rs")]).await.unwrap();
|
||||
assert!(f.status().await.is_empty());
|
||||
|
||||
// Main changes the same line: merging the branch conflicts.
|
||||
let ours = "pub fn format_price(cents: u64) -> String { // main";
|
||||
f.edit_line(lib, ours).await;
|
||||
f.save(lib).await;
|
||||
f.commit(&["src/lib.rs"], "Main's change").await;
|
||||
let merged = ops::vcs::vcs_merge(&f.host, WINDOW, None, "feature".into()).await;
|
||||
let status = f.status().await;
|
||||
let conflicted = changed(&status, "src/lib.rs").map(|s| (s.staged, s.unstaged));
|
||||
assert!(
|
||||
matches!(conflicted, Some((Some(ChangeKind::Conflicted), _) | (_, Some(ChangeKind::Conflicted)))),
|
||||
"merge: {merged:?}; status: {status:?}"
|
||||
);
|
||||
// The open file shows the conflict as Git left it.
|
||||
assert!(f.text(lib).await.contains("<<<<<<<"), "{}", f.text(lib).await);
|
||||
let versions = ops::vcs::vcs_conflict_versions(&f.host, WINDOW, lib_path.clone()).await.unwrap();
|
||||
let has = |side: &Option<String>, line: &str| side.as_deref().is_some_and(|t| t.contains(line));
|
||||
assert!(has(&versions.base, base) && has(&versions.ours, ours) && has(&versions.theirs, theirs), "{versions:?}");
|
||||
|
||||
// Resolved (Accept Theirs) and committed: a merge commit, nothing left.
|
||||
let resolved = versions.theirs.clone().unwrap();
|
||||
f.host.project(WINDOW).unwrap().documents.replace_all(lib, &resolved).unwrap();
|
||||
f.save(lib).await;
|
||||
f.commit(&["src/lib.rs"], "Merge feature").await;
|
||||
let head = f.head().await;
|
||||
assert_eq!(head.parents.len(), 2, "{head:?}");
|
||||
assert!(f.status().await.is_empty());
|
||||
assert_eq!(std::fs::read_to_string(&lib_path).unwrap(), resolved);
|
||||
}
|
||||
@@ -114,3 +114,54 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di
|
||||
assert_eq!(automatic.index.find_files("same.txt", 100).len(), 1);
|
||||
host.close_project("main");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn php_language_level_follows_the_member_of_the_file() {
|
||||
use ide_sdk::php::{PhpLanguageLevel, PhpLevelSource};
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let (api, legacy) = (base.join("api"), base.join("legacy"));
|
||||
for (member, composer) in [
|
||||
(&api, r#"{"require": {"php": "^7.4"}}"#),
|
||||
(&legacy, r#"{"require": {"php": "^7.4"}, "config": {"platform": {"php": "8.0.5"}}}"#),
|
||||
] {
|
||||
std::fs::create_dir(member).unwrap();
|
||||
std::fs::write(member.join("composer.json"), composer).unwrap();
|
||||
}
|
||||
let root = base.join("workspace");
|
||||
Workspace::save(&root, "PHP", vec![api.clone(), legacy.clone()], true).unwrap();
|
||||
std::fs::write(root.join("composer.json"), r#"{"require": {"php": "^8.2"}}"#).unwrap();
|
||||
let config_dir = base.join("config");
|
||||
ide_workspace::SettingsStore::load(&config_dir, Some(&legacy))
|
||||
.set(ide_workspace::SettingsScope::Project, "languages.php.languageLevel", "8.3".into())
|
||||
.unwrap();
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir,
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
let found = |version: &str, source| Some(PhpLanguageLevel { version: version.into(), source });
|
||||
let level = async |path: std::path::PathBuf| ops::sdk::sdk_php_language_level(&host, "main", path).await.unwrap();
|
||||
|
||||
let level_of_api = level(api.join("src/Invoice.php")).await;
|
||||
assert_eq!(level_of_api.effective, found("7.4.0", PhpLevelSource::Require));
|
||||
assert_eq!(level_of_api.auto, level_of_api.effective);
|
||||
assert_eq!(level_of_api.member.as_deref(), Some(api.as_path()));
|
||||
// The member's own settings, not the workspace's.
|
||||
let level_of_legacy = level(legacy.join("index.php")).await;
|
||||
assert_eq!(level_of_legacy.effective, found("8.3.0", PhpLevelSource::Settings));
|
||||
assert_eq!(level_of_legacy.auto, found("8.0.5", PhpLevelSource::Platform));
|
||||
// Outside every member: the workspace's own.
|
||||
let level_elsewhere = level(base.join("elsewhere.php")).await;
|
||||
assert_eq!(level_elsewhere.effective, found("8.2.0", PhpLevelSource::Require));
|
||||
assert_eq!(level_elsewhere.member, None);
|
||||
host.close_project("main");
|
||||
}
|
||||
@@ -105,6 +105,7 @@ pub trait HostApi: Send + Sync {
|
||||
args: Vec<String>,
|
||||
cwd: Option<&Path>,
|
||||
) -> Result<(), HostError>;
|
||||
fn open_location(&self, client: Option<&str>, path: &Path, line: u32, column: u32) -> Result<(), HostError>;
|
||||
/// A setting of the plugin as JSON (project over global over the schema default).
|
||||
fn setting(&self, root: Option<&Path>, key: &str) -> Option<String>;
|
||||
}
|
||||
|
||||
@@ -640,6 +640,21 @@ impl views::Host for StoreData {
|
||||
let title = clip(title, crate::runtime::MAX_TITLE);
|
||||
Ok(self.host.open_terminal(client.as_deref(), title, &program, args, cwd.as_deref()).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn open_location(&mut self, path: String, line: u32, column: u32) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let Some(root) = self.invocation.workspace_root.clone() else {
|
||||
return Ok(Err(wit::Error::Invalid("no project is open in this window".into())));
|
||||
};
|
||||
let real = match sandbox::resolve_in_root(&root, &path) {
|
||||
Ok(real) => real,
|
||||
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
|
||||
};
|
||||
let client = self.invocation.client.clone();
|
||||
Ok(self.host.open_location(client.as_deref(), &real, line, column).map_err(to_wit))
|
||||
}
|
||||
}
|
||||
|
||||
impl settings::Host for StoreData {
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
a50d1eb41ab8dd0c9c8d6bae8266906cd6eb5de8e6f6ca6f681990389472bb5b
|
||||
1f7402e2b1b4602e4073eb61837c3dc8840f9a4f7275d800dbd6fcf6ce5e40c3
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
2fb6aa879562733bf5b7f11dabe8fc6a415c9ef695868fe7845417a4245d2dc8
|
||||
4f6cd5b5e0c12e7dcbccf66a6ad97276bf968bc3ad26f0fd08e1f7765cbfee14
|
||||
@@ -12,6 +12,7 @@ activation = ["on-view:jobs", "on-command:*"]
|
||||
|
||||
[capabilities]
|
||||
required = ["process:jobs"]
|
||||
optional = ["project.read"]
|
||||
|
||||
[[contributes.views]]
|
||||
id = "jobs"
|
||||
|
||||
@@ -64,6 +64,8 @@ impl Views for Jobs {
|
||||
return self.follow();
|
||||
}
|
||||
ViewEvent::Button(b) if b == "follow" => return self.follow(),
|
||||
ViewEvent::Button(b) if b == "log" => return host::open_location("jobs.log", 2, 4),
|
||||
ViewEvent::Button(b) if b == "outside" => return host::open_location("../outside.log", 0, 0),
|
||||
_ => return Ok(()),
|
||||
}
|
||||
host::refresh_view(VIEW);
|
||||
|
||||
@@ -1 +1 @@
|
||||
25b0f419a68a3b1651d3c2774d834b05e40ede52bc5ed8c0eba6d27a204efe82
|
||||
3d4dd1b6365c7fbafdd3c1f99f4405e5ee40fc5e56c97da960adfe7af2e7edb8
|
||||
@@ -612,9 +612,18 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
|
||||
|
||||
// Intelephense reads it as `intelephense.environment.phpVersion` (`workspace/configuration`).
|
||||
if name == "intelephense"
|
||||
&& let Some(version) = crate::php::language_level(root, s)
|
||||
&& let Some(level) = crate::php::language_level(root, s)
|
||||
{
|
||||
merge_init_options(cfg, json!({ "environment": { "phpVersion": version } }));
|
||||
merge_init_options(cfg, json!({ "environment": { "phpVersion": level.version } }));
|
||||
}
|
||||
if name == "intelephense" {
|
||||
if let Some(stubs) = crate::php::intelephense_stubs(root, s) {
|
||||
merge_init_options(cfg, json!({ "stubs": stubs }));
|
||||
}
|
||||
let include_paths = crate::php::include_paths(root, s);
|
||||
if !include_paths.is_empty() {
|
||||
merge_init_options(cfg, json!({ "environment": { "includePaths": include_paths } }));
|
||||
}
|
||||
}
|
||||
|
||||
// Node-based servers (`#!/usr/bin/env node`) run on the selected Node.js.
|
||||
|
||||
+177
-11
@@ -1,24 +1,53 @@
|
||||
//! The PHP language level: the one chosen in Settings › PHP, else the one the root
|
||||
//! `composer.json` declares (`config.platform.php`, else the lowest version `require.php` allows);
|
||||
//! and the Intelephense storage that goes with it.
|
||||
//! the Intelephense storage that goes with it; and the stub sets and include paths Intelephense indexes.
|
||||
|
||||
use crate::apply::{merge, merge_init_options};
|
||||
use crate::apply::{merge, merge_init_options, setting_path};
|
||||
use ide_core::settings::Settings;
|
||||
use ide_language::LanguageServerConfig;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::Path;
|
||||
|
||||
/// The project's PHP version as `X.Y.Z`; `None` when neither the settings nor
|
||||
/// `composer.json` name one.
|
||||
pub fn language_level(root: &Path, s: &Settings) -> Option<String> {
|
||||
/// A PHP version and where it comes from.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PhpLanguageLevel {
|
||||
/// `X.Y.Z`.
|
||||
pub version: String,
|
||||
pub source: PhpLevelSource,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PhpLevelSource {
|
||||
/// `languages.php.languageLevel`.
|
||||
Settings,
|
||||
/// `config.platform.php` of `composer.json`.
|
||||
Platform,
|
||||
/// The lowest version `require.php` of `composer.json` allows.
|
||||
Require,
|
||||
}
|
||||
|
||||
/// The project's PHP version: the one chosen in the settings, else [`composer_level`];
|
||||
/// `None` when neither names one.
|
||||
pub fn language_level(root: &Path, s: &Settings) -> Option<PhpLanguageLevel> {
|
||||
let explicit = s.languages.php.language_level.trim();
|
||||
if !explicit.is_empty() {
|
||||
return version(explicit).map(dotted);
|
||||
return version(explicit).map(|v| PhpLanguageLevel::new(v, PhpLevelSource::Settings));
|
||||
}
|
||||
composer_level(root)
|
||||
}
|
||||
|
||||
/// The PHP version the root `composer.json` declares (the setting's Auto):
|
||||
/// `config.platform.php`, else the lowest version `require.php` allows.
|
||||
pub fn composer_level(root: &Path) -> Option<PhpLanguageLevel> {
|
||||
let text = std::fs::read_to_string(root.join("composer.json")).ok()?;
|
||||
let composer: Value = serde_json::from_str(&text).ok()?;
|
||||
let platform = composer.pointer("/config/platform/php").and_then(Value::as_str).and_then(version);
|
||||
platform.or_else(|| lowest_allowed(composer.pointer("/require/php")?.as_str()?)).map(dotted)
|
||||
if let Some(platform) = composer.pointer("/config/platform/php").and_then(Value::as_str).and_then(version) {
|
||||
return Some(PhpLanguageLevel::new(platform, PhpLevelSource::Platform));
|
||||
}
|
||||
lowest_allowed(composer.pointer("/require/php")?.as_str()?)
|
||||
.map(|v| PhpLanguageLevel::new(v, PhpLevelSource::Require))
|
||||
}
|
||||
|
||||
/// Gives each PHP version its own Intelephense workspace storage (below `data_dir`).
|
||||
@@ -48,10 +77,74 @@ pub fn configure_intelephense_storage(cfg: &mut LanguageServerConfig, data_dir:
|
||||
merge_init_options(cfg, json!({ "storagePath": storage.to_string_lossy() }));
|
||||
}
|
||||
|
||||
/// The stub sets Intelephense loads when `stubs` is not set (Intelephense 1.18.5). Setting
|
||||
/// `stubs` replaces them, so additions are sent together with these.
|
||||
#[rustfmt::skip]
|
||||
const DEFAULT_STUBS: &[&str] = &[
|
||||
"apache", "bcmath", "bz2", "calendar", "com_dotnet", "Core", "ctype", "curl", "date", "dba", "dom", "enchant",
|
||||
"exif", "fileinfo", "filter", "fpm", "ftp", "gd", "hash", "iconv", "imap", "intl", "json", "ldap", "libxml",
|
||||
"mbstring", "mcrypt", "mssql", "mysqli", "oci8", "odbc", "openssl", "pcntl", "pcre", "PDO", "pgsql", "Phar",
|
||||
"posix", "pspell", "random", "readline", "Reflection", "regex", "session", "shmop", "SimpleXML", "snmp", "soap",
|
||||
"sockets", "sodium", "SPL", "sqlite3", "standard", "superglobals", "sybase", "sysvmsg", "sysvsem", "sysvshm",
|
||||
"tidy", "tokenizer", "uri", "xml", "xmlreader", "xmlrpc", "xmlwriter", "Zend OPcache", "zip", "zlib",
|
||||
];
|
||||
|
||||
/// Stub sets outside the defaults whose names are not lower case; the rest are the lower-case
|
||||
/// extension name.
|
||||
const MIXED_CASE_STUBS: &[&str] =
|
||||
&["Ev", "FFI", "LuaSandbox", "Parle", "SQLite", "SaxonC", "SplType", "ZendCache", "ZendDebugger", "ZendUtils"];
|
||||
|
||||
/// `Zend OPcache`, `zend-opcache` and `zend_opcache` name the same stubs.
|
||||
fn stub_key(name: &str) -> String {
|
||||
name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()).collect()
|
||||
}
|
||||
|
||||
/// The `stubs` Intelephense loads: its defaults plus a stub set for each `ext-*` that the root
|
||||
/// `composer.json` requires (`require`, `require-dev`) and those in Settings › PHP. `None` when
|
||||
/// that adds nothing to the defaults, so the server keeps its own.
|
||||
pub fn intelephense_stubs(root: &Path, s: &Settings) -> Option<Vec<String>> {
|
||||
let composer: Option<Value> =
|
||||
std::fs::read_to_string(root.join("composer.json")).ok().and_then(|t| serde_json::from_str(&t).ok());
|
||||
let extensions = ["require", "require-dev"]
|
||||
.into_iter()
|
||||
.filter_map(|section| composer.as_ref()?.get(section)?.as_object())
|
||||
.flat_map(|deps| deps.keys())
|
||||
.filter_map(|dep| dep.strip_prefix("ext-"))
|
||||
.map(|ext| {
|
||||
let key = stub_key(ext);
|
||||
MIXED_CASE_STUBS
|
||||
.iter()
|
||||
.find(|stub| stub_key(stub) == key)
|
||||
.map_or(ext.to_ascii_lowercase(), |s| s.to_string())
|
||||
});
|
||||
let configured = s.languages.php.stubs.iter().map(|stub| stub.trim().to_owned());
|
||||
let mut stubs: Vec<String> = DEFAULT_STUBS.iter().map(|s| s.to_string()).collect();
|
||||
let mut known: std::collections::HashSet<String> = stubs.iter().map(|s| stub_key(s)).collect();
|
||||
for stub in extensions.chain(configured) {
|
||||
if !stub.is_empty() && known.insert(stub_key(&stub)) {
|
||||
stubs.push(stub);
|
||||
}
|
||||
}
|
||||
(stubs.len() > DEFAULT_STUBS.len()).then_some(stubs)
|
||||
}
|
||||
|
||||
/// The folders in Settings › PHP › Include paths, resolved against the project.
|
||||
pub fn include_paths(root: &Path, s: &Settings) -> Vec<String> {
|
||||
s.languages
|
||||
.php
|
||||
.include_paths
|
||||
.iter()
|
||||
.filter_map(|p| setting_path(root, p))
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
type Version = (u32, u32, u32);
|
||||
|
||||
fn dotted((major, minor, patch): Version) -> String {
|
||||
format!("{major}.{minor}.{patch}")
|
||||
impl PhpLanguageLevel {
|
||||
fn new((major, minor, patch): Version, source: PhpLevelSource) -> Self {
|
||||
Self { version: format!("{major}.{minor}.{patch}"), source }
|
||||
}
|
||||
}
|
||||
|
||||
/// `7`, `7.4`, `v7.4.3`, `8.1.*`, `7.4.0-dev`: missing parts are 0.
|
||||
@@ -105,13 +198,18 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn level(composer: Option<&str>, explicit: &str) -> Option<String> {
|
||||
levels(composer, explicit).0.map(|l| l.version)
|
||||
}
|
||||
|
||||
/// The effective level and the composer one.
|
||||
fn levels(composer: Option<&str>, explicit: &str) -> (Option<PhpLanguageLevel>, Option<PhpLanguageLevel>) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
if let Some(composer) = composer {
|
||||
std::fs::write(tmp.path().join("composer.json"), composer).unwrap();
|
||||
}
|
||||
let mut s = Settings::default();
|
||||
s.languages.php.language_level = explicit.into();
|
||||
language_level(tmp.path(), &s)
|
||||
(language_level(tmp.path(), &s), composer_level(tmp.path()))
|
||||
}
|
||||
|
||||
fn required(constraint: &str) -> Option<String> {
|
||||
@@ -201,6 +299,74 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn stubs(composer: Option<Value>, configured: &[&str]) -> Option<Vec<String>> {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
if let Some(composer) = composer {
|
||||
std::fs::write(tmp.path().join("composer.json"), composer.to_string()).unwrap();
|
||||
}
|
||||
let mut s = Settings::default();
|
||||
s.languages.php.stubs = configured.iter().map(|s| s.to_string()).collect();
|
||||
intelephense_stubs(tmp.path(), &s)
|
||||
}
|
||||
|
||||
fn added(stubs: Option<Vec<String>>) -> Vec<String> {
|
||||
let stubs = stubs.expect("stubs");
|
||||
assert_eq!(stubs[..DEFAULT_STUBS.len()], DEFAULT_STUBS.iter().map(|s| s.to_string()).collect::<Vec<_>>());
|
||||
stubs[DEFAULT_STUBS.len()..].to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_extensions_and_configured_stubs_follow_the_defaults() {
|
||||
let composer = json!({
|
||||
"require": {"php": "^8.1", "ext-redis": "*", "ext-json": "*", "monolog/monolog": "^3"},
|
||||
"require-dev": {"ext-Imagick": "*", "ext-ffi": "*", "ext-redis": "*"}
|
||||
});
|
||||
assert_eq!(
|
||||
added(stubs(Some(composer), &[" wordpress ", "Redis", ""])),
|
||||
["redis", "imagick", "FFI", "wordpress"]
|
||||
);
|
||||
assert_eq!(added(stubs(None, &["wordpress"])), ["wordpress"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_beyond_the_defaults_sends_nothing() {
|
||||
assert_eq!(stubs(None, &[]), None);
|
||||
assert_eq!(stubs(Some(json!({"require": {"php": "^8.1"}})), &[]), None);
|
||||
// Default stub sets under their extension names: `ext-pdo`, `ext-zend-opcache`, `ext-simplexml`.
|
||||
let defaults =
|
||||
json!({"require": {"ext-pdo": "*", "ext-zend-opcache": "*", "ext-simplexml": "*", "ext-json": "*"}});
|
||||
assert_eq!(stubs(Some(defaults), &["SPL", "core"]), None);
|
||||
assert_eq!(stubs(Some(json!("not an object")), &[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn include_paths_resolve_against_the_project() {
|
||||
let root = Path::new("/work/module");
|
||||
let mut s = Settings::default();
|
||||
s.languages.php.include_paths = vec!["../platform".into(), "/opt/prestashop".into(), " ".into()];
|
||||
assert_eq!(
|
||||
include_paths(root, &s),
|
||||
[root.join("../platform").to_string_lossy().into_owned(), "/opt/prestashop".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_level_names_its_source() {
|
||||
let found = |version: &str, source| Some(PhpLanguageLevel { version: version.into(), source });
|
||||
let require = r#"{"require": {"php": "^7.4"}}"#;
|
||||
let platform = r#"{"require": {"php": "^7.4"}, "config": {"platform": {"php": "8.2.1"}}}"#;
|
||||
|
||||
let require_level = found("7.4.0", PhpLevelSource::Require);
|
||||
assert_eq!(levels(Some(require), ""), (require_level.clone(), require_level.clone()));
|
||||
let platform_level = found("8.2.1", PhpLevelSource::Platform);
|
||||
assert_eq!(levels(Some(platform), ""), (platform_level.clone(), platform_level));
|
||||
// An explicit level: the composer one is still what Auto would pick.
|
||||
assert_eq!(levels(Some(require), "8.1"), (found("8.1.0", PhpLevelSource::Settings), require_level));
|
||||
assert_eq!(levels(None, "8.1"), (found("8.1.0", PhpLevelSource::Settings), None));
|
||||
assert_eq!(levels(None, ""), (None, None));
|
||||
assert_eq!(levels(Some(r#"{"require": {"php": "*"}}"#), ""), (None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_level_wins() {
|
||||
let composer = r#"{"require": {"php": "^7.4"}, "config": {"platform": {"php": "7.4.33"}}}"#;
|
||||
|
||||
@@ -1325,6 +1325,30 @@ fn intelephense_gets_the_php_language_level() {
|
||||
assert_eq!(options(cfg), json!({"clearCache": true, "environment": {"phpVersion": "8.1.0"}}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intelephense_gets_the_required_stubs_and_include_paths() {
|
||||
use serde_json::json;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(tmp.path().join("composer.json"), r#"{"require": {"php": "^8.1", "ext-redis": "*"}}"#).unwrap();
|
||||
let mut s = Settings::default();
|
||||
s.languages.php.stubs = vec!["wordpress".into()];
|
||||
s.languages.php.include_paths = vec!["../platform".into()];
|
||||
let mut cfg = server("intelephense");
|
||||
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
|
||||
let options = serde_json::to_value(cfg.initialization_options).unwrap();
|
||||
let stubs: Vec<&str> = options["stubs"].as_array().unwrap().iter().filter_map(|v| v.as_str()).collect();
|
||||
assert_eq!(stubs.len(), 70);
|
||||
assert_eq!((stubs[0], &stubs[68..]), ("apache", &["redis", "wordpress"][..]));
|
||||
assert_eq!(
|
||||
options["environment"],
|
||||
json!({"phpVersion": "8.1.0", "includePaths": [tmp.path().join("../platform").to_string_lossy()]})
|
||||
);
|
||||
|
||||
let mut other = server("phpactor");
|
||||
configure_server(&mut other, tmp.path(), tmp.path(), &s);
|
||||
assert_eq!(other.initialization_options, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_constraints_change_only_protocol_settings() {
|
||||
use serde_json::json;
|
||||
|
||||
+3
-1
@@ -20,7 +20,9 @@ The fixtures are standalone: `rust-app` declares its own `[workspace]` and
|
||||
|
||||
The language smoke test (`crates/ide-host/tests/language_smoke.rs`) runs completion,
|
||||
navigation, diagnostics, quick fixes, formatting and rename on these projects with real
|
||||
language servers: when you change a fixture, run it for that language.
|
||||
language servers, and `smoke/run.test.ts` runs each project's run and test configurations
|
||||
(the intentionally failing test included, then Rerun Failed Tests): when you change a
|
||||
fixture, run both for that language.
|
||||
|
||||
## Setup
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ const config: KnipConfig = {
|
||||
"scripts/**/*.ts",
|
||||
"perf/**/*.ts",
|
||||
"e2e/**/*.ts",
|
||||
"smoke/**/*.ts",
|
||||
],
|
||||
// Plugins' web pages are projects of their own (own package.json, checked by plugins/build.sh and CI).
|
||||
ignore: ["plugins/**/web-src/**"],
|
||||
|
||||
+2
-1
@@ -12,11 +12,12 @@
|
||||
"tauri": "node scripts/tauri.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"smoke": "vitest run --config smoke/vitest.config.ts",
|
||||
"lint": "oxlint",
|
||||
"format": "oxfmt",
|
||||
"format:check": "oxfmt --check",
|
||||
"knip": "knip",
|
||||
"typecheck": "tsrs --noEmit && tsrs -p tsconfig.node.json --noEmit && tsrs -p perf --noEmit && tsrs -p scripts --noEmit",
|
||||
"typecheck": "tsrs --noEmit && tsrs -p tsconfig.node.json --noEmit && tsrs -p perf --noEmit && tsrs -p scripts --noEmit && tsrs -p smoke --noEmit",
|
||||
"gen:bindings": "cargo test -p openidea --test export_bindings",
|
||||
"gen:windows-icons": "node scripts/gen-windows-icons.ts",
|
||||
"gen:angular-grammar": "lezer-generator src/editor/core/cm/angular/angular.grammar --typeScript -o src/editor/core/cm/angular/parser.ts",
|
||||
|
||||
+18
-17
@@ -125,23 +125,23 @@ work), but 0.1 code no longer runs: rebuild it against the 1.0 SDK.
|
||||
|
||||
### Contribution points
|
||||
|
||||
| Point | Declaration | Code needed |
|
||||
| --------------- | -------------------------------------------------------------------------------------------------- | ------------- |
|
||||
| `commands` | `id`, `title`, `category?`, `when?` | to run them |
|
||||
| `themes` | `path` (theme JSON), `editor-scheme?` | no |
|
||||
| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no |
|
||||
| `languages` | `path` (`languages/<id>.toml` format, see §6) | no |
|
||||
| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no |
|
||||
| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes |
|
||||
| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them |
|
||||
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `tools`), `command`, `when?` | no |
|
||||
| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no |
|
||||
| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no |
|
||||
| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no |
|
||||
| `views` | `id`, `title`, `icon?`, `location` (`left`, `right`, `bottom`), `web?` (see _Web views_) | without `web` |
|
||||
| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` |
|
||||
| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no |
|
||||
| `fileTemplates` | `id`, `name`, `extension`, `path` (the new file's content; template variables work) — New menu | no |
|
||||
| Point | Declaration | Code needed |
|
||||
| --------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------- |
|
||||
| `commands` | `id`, `title`, `category?`, `when?` | to run them |
|
||||
| `themes` | `path` (theme JSON), `editor-scheme?` | no |
|
||||
| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no |
|
||||
| `languages` | `path` (`languages/<id>.toml` format, see §6) | no |
|
||||
| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no |
|
||||
| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes |
|
||||
| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them |
|
||||
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `tools`), `command`, `when?` | no |
|
||||
| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no |
|
||||
| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no |
|
||||
| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no |
|
||||
| `views` | `id`, `title`, `icon?` (an icon name, or an SVG of the package), `location` (`left`, `right`, `bottom`), `web?` (see _Web views_) | without `web` |
|
||||
| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` |
|
||||
| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no |
|
||||
| `fileTemplates` | `id`, `name`, `extension`, `path` (the new file's content; template variables work) — New menu | no |
|
||||
|
||||
Declared contributions are live as soon as the plugin is enabled — commands
|
||||
show in Find Action, menus and the status bar, shortcuts work, themes and icon
|
||||
@@ -313,6 +313,7 @@ then run `openidea-plugin check`.
|
||||
| `host::refresh_view(view)` | `views.refresh` | The view (or custom editors) render again; call it when the content changed. |
|
||||
| `host::post_view_message(view, text)` | `views.post-message` | To the script of the view's `html` body. |
|
||||
| `host::open_terminal(title, program, args)` | `views.open-terminal` | A terminal tab in the invoking window running the approved `process:<program>` in the project root: output that keeps coming, interactive sessions. |
|
||||
| `host::open_location(path, line, column)` | `views.open-location` | Opens a file inside the project in an editor of the invoking window, the caret at `line`/`column` (0-based, UTF-16): a view's links to code. Needs `project.read`. |
|
||||
| `host::setting(key)`, `setting_bool`, `setting_string`, `setting_number` | `settings.get` | The user's value (project over global) or the schema's default. |
|
||||
|
||||
`run-command` receives an optional string argument: `executeAction("plugin.<plugin id>.<command id>", "arg")`
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
28e4bf10a41bdda011111b0e150ebe96205c57f410bad98c19eea3f1286349d6
|
||||
9ad412cb7b853d40467c67305a1126bb87910d179ecfa1c88e13d4a900367c50
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
7e015486cc916f5edbac542863373fd7204f5bcd534f6c408d5f9d2b72913862
|
||||
49a3c6bf2511ba137457397203244bdfca3d6e10f62dbc7fde9473dab07f6457
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
a6cf59e74595e13908e2cbf1752c58665c6963de258413c9d7332d2a7dd9cbd0
|
||||
c199dc52e00d3943882d532e7e6095f7b2f8d7d6026cf1ccaa685dd6300f2319
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
b8dde206a74b222b93e61056d4cbe403617ae240d142e01e91420a2aacdb2da4
|
||||
6047be5eeaa1330d7aa1f182e88ae2f352f244829ad9a01fdc3a2a081bdca95d
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
a681c2abd4a65d6aeb62efd514e9b170f4fca52b0c14d2d380a3b04be0a3fbc6
|
||||
7a3ee410d228d1e39155aa042237d222d23d7f5a45e261086cffc45c6ee1ad32
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
3c83bc9559df54457e19e650cdd21bab9d609e22daaa1fc4f596910b70834a34
|
||||
7658a95eb9fc27468cae4b1d20e3b5ab299fde478e07f69ab0fdb4cc10caf11d
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
63f1bbdf6eca5a4642cd3453ae384ff3f876de0373c12008b757a63852164b71
|
||||
227a30ef0028f167a20825bdd775b15392161e641f6c68030e01d0c79e263bb5
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
0db5cb24c358651d0e728ab3efb52286290c1a38ec379435225c935d4165ad6a
|
||||
046f769822102de0d4ecec99ad41027cb792941065226716f38a6e64c50ff364
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
257478b050ea360c55e904f2a89c6069293139ff0d97ba860de3f29dac927787
|
||||
0b9c3572390a3caab7d47f9b975439f8dde3feff6b9044579d99b8334ac2a692
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
21e7510d6a3712bf0f21d53a5c8d652e3c2787d89a1fc25998cde96efe124207
|
||||
0af62bcd4583d126a616cafbf2ddc80f039a3ead2b5dcb7144222b0ebc673432
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
04844bfbfbcb1770f4f4742658944c592922b037b2387e9340de58ef0d9baacc
|
||||
24ef150beefb05622212228e1c44cb487fffe6526776762d713f3ebdf4c02ac0
|
||||
@@ -245,6 +245,10 @@ mod imp {
|
||||
wit::views::open_terminal(title, program, args).map_err(error)
|
||||
}
|
||||
|
||||
pub fn open_location(path: &str, line: u32, column: u32) -> Result<(), Error> {
|
||||
wit::views::open_location(path, line, column).map_err(error)
|
||||
}
|
||||
|
||||
pub fn setting(key: &str) -> Option<String> {
|
||||
wit::settings::get(key)
|
||||
}
|
||||
@@ -387,6 +391,10 @@ pub fn open_terminal(title: &str, program: &str, args: &[String]) -> Result<(),
|
||||
imp::open_terminal(title, program, args)
|
||||
}
|
||||
|
||||
pub fn open_location(path: &str, line: u32, column: u32) -> Result<(), Error> {
|
||||
imp::open_location(path, line, column)
|
||||
}
|
||||
|
||||
/// One of the plugin's settings (`[contributes.settings]`) as JSON: the
|
||||
/// user's value (project over global) or the schema's default.
|
||||
pub fn setting(key: &str) -> Option<String> {
|
||||
|
||||
@@ -74,6 +74,7 @@ struct State {
|
||||
refreshed: Vec<String>,
|
||||
view_messages: Vec<(String, String)>,
|
||||
terminals: Vec<MockTerminal>,
|
||||
locations: Vec<(String, u32, u32)>,
|
||||
settings: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
@@ -220,6 +221,10 @@ impl MockHost {
|
||||
self.state.borrow().view_messages.clone()
|
||||
}
|
||||
|
||||
pub fn opened_locations(&self) -> Vec<(String, u32, u32)> {
|
||||
self.state.borrow().locations.clone()
|
||||
}
|
||||
|
||||
/// Terminals the plugin opened.
|
||||
pub fn terminals(&self) -> Vec<MockTerminal> {
|
||||
self.state.borrow().terminals.clone()
|
||||
@@ -595,6 +600,14 @@ pub mod current {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn open_location(path: &str, line: u32, column: u32) -> Result<(), Error> {
|
||||
with(|s| {
|
||||
require(s, "project.read")?;
|
||||
s.locations.push((path.into(), line, column));
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn setting(key: &str) -> Option<String> {
|
||||
with(|s| s.settings.get(key).cloned())
|
||||
}
|
||||
|
||||
@@ -133,6 +133,10 @@ interface views {
|
||||
/// `program` (pinned by a `process:<program>` grant) with `args` in the
|
||||
/// project root: long-running output (logs) and interactive sessions.
|
||||
open-terminal: func(title: string, program: string, args: list<string>) -> result<_, error>;
|
||||
/// Opens `path` (project-relative or absolute, inside the project) in an
|
||||
/// editor of the window the call came from, the caret at `line` and
|
||||
/// `column` (0-based; UTF-16 column): a view's links to code.
|
||||
open-location: func(path: string, line: u32, column: u32) -> result<_, error>;
|
||||
}
|
||||
|
||||
/// The plugin's own settings (`[contributes.settings]`), as the user set them
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* The frontend's view of the desktop app, with a real backend: the window's Tauri runtime
|
||||
* (callbacks, `Channel`s, the event plugin) in-process, and every app command sent to an
|
||||
* `openidea-server` through the route remote development uses (`src/platform/tauriCore.ts`).
|
||||
*/
|
||||
import { setInvokeRoute } from "@/platform/tauriCore";
|
||||
import { encodeArgs } from "@/platform/remote";
|
||||
import { Server } from "./server";
|
||||
|
||||
type Callback = (message: unknown) => void;
|
||||
|
||||
// One runtime per page, as in the app: the frontend registers its event listeners once
|
||||
// (`src/platform/ipc.ts`), so they outlive the servers connected one after another.
|
||||
const callbacks = new Map<number, Callback>();
|
||||
const listeners: { event: string; handler: number }[] = [];
|
||||
const channels = new Map<number, { onmessage: (data: unknown) => void }>();
|
||||
let nextCallback = 1;
|
||||
let installed = false;
|
||||
|
||||
function install() {
|
||||
if (installed) return;
|
||||
installed = true;
|
||||
const internals = {
|
||||
metadata: { currentWindow: { label: "main" }, currentWebview: { label: "main", windowLabel: "main" } },
|
||||
transformCallback(cb: Callback) {
|
||||
const id = nextCallback++;
|
||||
callbacks.set(id, cb);
|
||||
return id;
|
||||
},
|
||||
unregisterCallback(id: number) {
|
||||
callbacks.delete(id);
|
||||
},
|
||||
convertFileSrc: (path: string) => path,
|
||||
// Only the window's own plugins reach this: app commands take the route below.
|
||||
async invoke(cmd: string, args: Record<string, unknown> = {}) {
|
||||
if (cmd === "plugin:event|listen") {
|
||||
listeners.push({ event: args.event as string, handler: args.handler as number });
|
||||
return listeners.length;
|
||||
}
|
||||
if (cmd === "plugin:window|get_all_windows") return [];
|
||||
if (cmd.startsWith("plugin:")) return null;
|
||||
throw { kind: "unavailable", message: `${cmd} has no route`, detail: null };
|
||||
},
|
||||
};
|
||||
Object.assign(window, {
|
||||
__TAURI_INTERNALS__: internals,
|
||||
__TAURI_EVENT_PLUGIN_INTERNALS__: { unregisterListener: () => {} },
|
||||
});
|
||||
}
|
||||
|
||||
/** Connects this page's frontend to `server`; returns a function that disconnects it. */
|
||||
export function connectDesktop(server: Server): () => void {
|
||||
install();
|
||||
channels.clear();
|
||||
server.onChannel = (id, data) => channels.get(id)?.onmessage(data);
|
||||
server.onEvent = (target, event) => {
|
||||
if (target !== null && target !== "main") return;
|
||||
for (const l of listeners) {
|
||||
if (l.event === "ide://event") callbacks.get(l.handler)?.({ event: l.event, id: 0, payload: event });
|
||||
}
|
||||
};
|
||||
setInvokeRoute((cmd, args) => {
|
||||
if (cmd.startsWith("plugin:")) return null;
|
||||
const { json, channels: used } = encodeArgs(args);
|
||||
for (const [id, channel] of used) channels.set(id, channel as unknown as { onmessage: (data: unknown) => void });
|
||||
return server.invoke(cmd, JSON.parse(json));
|
||||
});
|
||||
return () => {
|
||||
setInvokeRoute(null);
|
||||
server.onChannel = () => {};
|
||||
server.onEvent = () => {};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Running and testing each language's `fixtures/` project the way the Run tool window does:
|
||||
* the backend detects the configurations, the run store starts them (with its test
|
||||
* reporting), parses the output into the test tree, and reruns the failed tests.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { cpSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { commands, type RunConfig } from "@/bindings";
|
||||
import { onIdeEvent } from "@/platform/ipc";
|
||||
import { openProject, useProject } from "@/platform/project";
|
||||
import {
|
||||
failedTests,
|
||||
loadConfigs,
|
||||
onRunExited,
|
||||
rerunFailed,
|
||||
resetRuns,
|
||||
runtime,
|
||||
startRun,
|
||||
stopSession,
|
||||
useRun,
|
||||
waitForExit,
|
||||
} from "@/features/run/store";
|
||||
import { nodeStatus } from "@/features/run/testing/results";
|
||||
import { connectDesktop } from "./desktop";
|
||||
import { repoRoot, Server } from "./server";
|
||||
|
||||
interface Case {
|
||||
fixture: string;
|
||||
/** Runs in the project copy first (installs its dependencies). */
|
||||
setup?: (root: string) => void;
|
||||
/** A configuration that runs to completion printing this, or a server stopped once it prints it. */
|
||||
run?: { config: string; prints: string; stop?: boolean };
|
||||
tests: {
|
||||
config: string;
|
||||
/** The configuration with the fixture's intentionally failing test switched on. */
|
||||
failing: (config: RunConfig) => RunConfig;
|
||||
passed: number;
|
||||
failed: string;
|
||||
/** The failure's location ends with this file. */
|
||||
file: string;
|
||||
};
|
||||
}
|
||||
|
||||
const env = (config: RunConfig, vars: Record<string, string>): RunConfig => ({
|
||||
...config,
|
||||
env: { ...config.env, ...vars },
|
||||
});
|
||||
const args = (config: RunConfig, extra: string[]): RunConfig => ({ ...config, args: [...config.args, ...extra] });
|
||||
|
||||
const CASES: Record<string, Case> = {
|
||||
typescript: {
|
||||
fixture: "ts-app",
|
||||
setup: (root) => execFileSync("npm", ["install", "--no-audit", "--no-fund"], { cwd: root, stdio: "ignore" }),
|
||||
run: { config: "npm:start", prints: "Total: $134.97" },
|
||||
tests: {
|
||||
config: "npm:test",
|
||||
failing: (c) => env(c, { OPENIDEA_FAIL: "1" }),
|
||||
passed: 4,
|
||||
failed: "intentionally fails (set OPENIDEA_FAIL=1)",
|
||||
file: "tests/cart.test.ts",
|
||||
},
|
||||
},
|
||||
python: {
|
||||
fixture: "python-app",
|
||||
// A project venv with pytest, as the fixture's README sets it up.
|
||||
setup: (root) => {
|
||||
execFileSync("python3", ["-m", "venv", ".venv"], { cwd: root, stdio: "ignore" });
|
||||
execFileSync(join(root, ".venv", "bin", "pip"), ["install", "-q", "pytest"], { cwd: root, stdio: "ignore" });
|
||||
},
|
||||
tests: {
|
||||
config: "pytest",
|
||||
failing: (c) => env(c, { OPENIDEA_FAIL: "1" }),
|
||||
passed: 4,
|
||||
failed: "test_intentionally_failing",
|
||||
file: "tests/test_models.py",
|
||||
},
|
||||
},
|
||||
rust: {
|
||||
fixture: "rust-app",
|
||||
run: { config: "cargo:run:inventory", prints: "Sold 3 mice, 9 left" },
|
||||
tests: {
|
||||
config: "cargo:test",
|
||||
failing: (c) => args(c, ["--features", "failing-tests"]),
|
||||
// `cargo test` stops after the failing unit tests: the integration tests don't run.
|
||||
passed: 7,
|
||||
failed: "intentionally_failing",
|
||||
file: "src/lib.rs",
|
||||
},
|
||||
},
|
||||
go: {
|
||||
fixture: "go-app",
|
||||
run: { config: "go:run:server", prints: "listening on", stop: true },
|
||||
tests: {
|
||||
config: "go:test",
|
||||
failing: (c) => env(c, { OPENIDEA_FAIL: "1" }),
|
||||
passed: 3,
|
||||
failed: "TestIntentionallyFailing",
|
||||
file: "store_test.go",
|
||||
},
|
||||
},
|
||||
java: {
|
||||
fixture: "java-app",
|
||||
tests: {
|
||||
config: "maven:test",
|
||||
failing: (c) => args(c, ["-DopenideaFail=true"]),
|
||||
passed: 2,
|
||||
failed: "intentionallyFails",
|
||||
file: "OrderBookTest.java",
|
||||
},
|
||||
},
|
||||
kotlin: {
|
||||
fixture: "kotlin-gradle",
|
||||
run: { config: "gradle:run", prints: "Lent 'Kotlin in Action' until 2024-01-15" },
|
||||
tests: {
|
||||
config: "gradle:test",
|
||||
failing: (c) => args(c, ["-PopenideaFail"]),
|
||||
passed: 3,
|
||||
failed: "intentionally failing",
|
||||
file: "LibraryTest.kt",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let server: Server | null = null;
|
||||
let disconnect: (() => void) | null = null;
|
||||
let base = "";
|
||||
|
||||
afterEach(() => {
|
||||
// As switching projects does: run ids restart with each server.
|
||||
resetRuns();
|
||||
disconnect?.();
|
||||
server?.close();
|
||||
if (base) rmSync(base, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function open(c: Case): Promise<string> {
|
||||
base = realpathSync(mkdtempSync(join(tmpdir(), "openidea-smoke-")));
|
||||
const root = join(base, c.fixture);
|
||||
cpSync(join(repoRoot, "fixtures", c.fixture), root, { recursive: true });
|
||||
c.setup?.(root);
|
||||
server = await Server.start(join(base, "home"));
|
||||
disconnect = connectDesktop(server);
|
||||
const unsubscribe = onIdeEvent("runExited", (e) => onRunExited(e.run, e.code));
|
||||
const previous = disconnect;
|
||||
disconnect = () => {
|
||||
unsubscribe();
|
||||
previous();
|
||||
};
|
||||
await openProject(root);
|
||||
const trusted = await commands.projectTrustSet(root, true, false);
|
||||
if (trusted.status !== "ok") throw new Error(trusted.error.message);
|
||||
useProject.setState({ project: { ...useProject.getState().project!, trusted: true } });
|
||||
await loadConfigs();
|
||||
return root;
|
||||
}
|
||||
|
||||
function config(id: string): RunConfig {
|
||||
const found = useRun.getState().configs.find((c) => c.id === id);
|
||||
if (!found)
|
||||
throw new Error(
|
||||
`no configuration ${id}: ${useRun
|
||||
.getState()
|
||||
.configs.map((c) => c.id)
|
||||
.join(", ")}`,
|
||||
);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Runs `config` to its end; returns its session id and output. */
|
||||
async function runToEnd(config: RunConfig, timeoutMs = 8 * 60_000): Promise<{ id: string; output: string[] }> {
|
||||
const output: string[] = [];
|
||||
const session = await startRun(config, undefined, undefined, { prepared: true, onLine: (l) => output.push(l) });
|
||||
if (!session) throw new Error(`${config.id} didn't start`);
|
||||
await waitForExit([session.id], timeoutMs);
|
||||
const ended = useRun.getState().sessions.find((s) => s.id === session.id)!;
|
||||
expect(ended.status, `${config.id} is still running:\n${output.join("\n")}`).not.toBe("running");
|
||||
return { id: session.id, output };
|
||||
}
|
||||
|
||||
async function smoke(language: string) {
|
||||
const c = CASES[language]!;
|
||||
await open(c);
|
||||
|
||||
if (c.run) {
|
||||
const output: string[] = [];
|
||||
const session = await startRun(config(c.run.config), undefined, undefined, {
|
||||
prepared: true,
|
||||
onLine: (l) => output.push(l),
|
||||
});
|
||||
expect(session, "the run starts").toBeTruthy();
|
||||
await expect.poll(() => output.join("\n"), { timeout: 5 * 60_000 }).toContain(c.run.prints);
|
||||
if (c.run.stop) await stopSession(session!.id);
|
||||
await waitForExit([session!.id], 60_000);
|
||||
const ended = useRun.getState().sessions.find((s) => s.id === session!.id)!;
|
||||
expect(ended.status, output.join("\n")).toBe("finished");
|
||||
if (!c.run.stop) expect(ended.exitCode, output.join("\n")).toBe(0);
|
||||
}
|
||||
|
||||
// Tests, with the intentionally failing one switched on: one failure, located, the rest passed.
|
||||
const { id, output } = await runToEnd(c.tests.failing(config(c.tests.config)));
|
||||
const tests = runtime(id)!.tests!;
|
||||
const log = `\n--- output ---\n${output.slice(-80).join("\n")}`;
|
||||
expect(tests.counts(), log).toMatchObject({ passed: c.tests.passed, failed: 1 });
|
||||
const failure = tests.firstFailure()!;
|
||||
expect(failure.name, log).toBe(c.tests.failed);
|
||||
expect(failure.location?.path ?? "", `${JSON.stringify(failure.location)}${log}`).toMatch(
|
||||
new RegExp(`${c.tests.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`),
|
||||
);
|
||||
expect(failure.output.join("\n"), "the failure's message is kept").not.toBe("");
|
||||
expect(failedTests(id)).toHaveLength(1);
|
||||
|
||||
// Rerun Failed Tests runs that one test only.
|
||||
const rerunOutput: string[] = [];
|
||||
const before = useRun.getState().sessions.find((s) => s.id === id)!.startedAt;
|
||||
expect(rerunFailed(id), "Rerun Failed Tests is offered").toBe(true);
|
||||
const ended = () => {
|
||||
const s = useRun.getState().sessions.find((s) => s.id === id)!;
|
||||
return s.startedAt > before && s.endedAt !== null && s.endedAt >= s.startedAt;
|
||||
};
|
||||
await expect.poll(ended, { timeout: 8 * 60_000 }).toBe(true);
|
||||
const again = runtime(id)!.tests!;
|
||||
rerunOutput.push(...again.roots.map((r) => `${r.name}: ${nodeStatus(r)}`));
|
||||
expect(again.counts(), rerunOutput.join("\n")).toMatchObject({ passed: 0, failed: 1 });
|
||||
expect(again.firstFailure()?.name).toBe(c.tests.failed);
|
||||
}
|
||||
|
||||
for (const language of Object.keys(CASES)) {
|
||||
test(language, () => smoke(language));
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* `openidea-server --stdio`, spoken to as the desktop app does in remote development:
|
||||
* JSON-RPC with `Content-Length` framing over the server's stdin / stdout.
|
||||
*/
|
||||
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const PROTOCOL_VERSION = 2;
|
||||
const WINDOW = "main";
|
||||
|
||||
// Not `new URL(…, import.meta.url)`: the jsdom environment's `URL` isn't Node's.
|
||||
export const repoRoot = join(dirname(import.meta.filename), "..");
|
||||
|
||||
/** The server binary: `OPENIDEA_SERVER`, else the workspace's debug build. */
|
||||
function serverBinary(): string {
|
||||
const exe = process.platform === "win32" ? ".exe" : "";
|
||||
return process.env.OPENIDEA_SERVER ?? join(repoRoot, "target", "debug", `openidea-server${exe}`);
|
||||
}
|
||||
|
||||
interface Pending {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export class Server {
|
||||
private nextId = 1;
|
||||
private readonly pending = new Map<number, Pending>();
|
||||
private buffer = Buffer.alloc(0);
|
||||
private readonly stderr: string[] = [];
|
||||
/** `channel` notifications: a value streamed to the `Channel` with this id. */
|
||||
onChannel: (id: number, data: unknown) => void = () => {};
|
||||
/** `event` notifications: an `IdeEvent` for a window (`null`: every window). */
|
||||
onEvent: (window: string | null, event: unknown) => void = () => {};
|
||||
|
||||
private constructor(private readonly child: ChildProcessWithoutNullStreams) {
|
||||
child.stdout.on("data", (chunk: Buffer) => this.receive(chunk));
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
this.stderr.push(chunk.toString());
|
||||
if (this.stderr.length > 200) this.stderr.shift();
|
||||
});
|
||||
child.on("exit", (code) => {
|
||||
for (const p of this.pending.values()) p.reject(new Error(`openidea-server exited (${code}): ${this.log()}`));
|
||||
this.pending.clear();
|
||||
});
|
||||
}
|
||||
|
||||
/** Starts a server whose settings, caches and tools live under `home`. */
|
||||
static async start(home: string, env: Record<string, string> = {}): Promise<Server> {
|
||||
const child = spawn(serverBinary(), ["--stdio"], {
|
||||
env: { ...process.env, OPENIDEA_SERVER_HOME: home, ...env },
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
const server = new Server(child);
|
||||
await server.request("initialize", { version: PROTOCOL_VERSION });
|
||||
return server;
|
||||
}
|
||||
|
||||
/** The tail of the server's log, for failure messages. */
|
||||
log(): string {
|
||||
return this.stderr.join("").split("\n").slice(-40).join("\n");
|
||||
}
|
||||
|
||||
/** Runs an IPC command; rejects with the command's `IdeError`. */
|
||||
async invoke(cmd: string, args: unknown): Promise<unknown> {
|
||||
try {
|
||||
return await this.request("invoke", { window: WINDOW, cmd, args });
|
||||
} catch (e) {
|
||||
const message = (e as { message?: string }).message ?? String(e);
|
||||
try {
|
||||
throw JSON.parse(message);
|
||||
} catch (parsed) {
|
||||
if (parsed instanceof SyntaxError) throw { kind: "protocol", message, detail: null };
|
||||
throw parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.child.stdin.end();
|
||||
this.child.kill();
|
||||
}
|
||||
|
||||
private request(method: string, params: unknown): Promise<unknown> {
|
||||
const id = this.nextId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject });
|
||||
this.send({ jsonrpc: "2.0", id, method, params });
|
||||
});
|
||||
}
|
||||
|
||||
private send(message: unknown) {
|
||||
const body = Buffer.from(JSON.stringify(message));
|
||||
this.child.stdin.write(`Content-Length: ${body.length}\r\n\r\n`);
|
||||
this.child.stdin.write(body);
|
||||
}
|
||||
|
||||
private receive(chunk: Buffer) {
|
||||
this.buffer = Buffer.concat([this.buffer, chunk]);
|
||||
for (;;) {
|
||||
const end = this.buffer.indexOf("\r\n\r\n");
|
||||
if (end < 0) return;
|
||||
const length = /content-length:\s*(\d+)/i.exec(this.buffer.subarray(0, end).toString())?.[1];
|
||||
if (!length) throw new Error(`bad header: ${this.buffer.subarray(0, end)}`);
|
||||
const start = end + 4;
|
||||
if (this.buffer.length < start + Number(length)) return;
|
||||
const message = JSON.parse(this.buffer.subarray(start, start + Number(length)).toString());
|
||||
this.buffer = this.buffer.subarray(start + Number(length));
|
||||
this.dispatch(message);
|
||||
}
|
||||
}
|
||||
|
||||
private dispatch(message: {
|
||||
id?: number;
|
||||
method?: string;
|
||||
params?: { id?: number; data?: unknown; window?: string | null; event?: unknown };
|
||||
result?: unknown;
|
||||
error?: { message: string };
|
||||
}) {
|
||||
if (message.method && message.id !== undefined) {
|
||||
this.send({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "not supported" } });
|
||||
} else if (message.method === "channel") {
|
||||
this.onChannel(message.params!.id!, message.params!.data);
|
||||
} else if (message.method === "event") {
|
||||
this.onEvent(message.params!.window ?? null, message.params!.event);
|
||||
} else if (message.id !== undefined) {
|
||||
const pending = this.pending.get(message.id);
|
||||
this.pending.delete(message.id);
|
||||
if (message.error) pending?.reject(message.error);
|
||||
else pending?.resolve(message.result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node", "vite/client"],
|
||||
"paths": { "@/*": ["../src/*"] }
|
||||
},
|
||||
"include": ["./**/*.ts", "../src/**/*.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Backend smoke tests (`pnpm smoke`): the frontend's own modules driving a real
|
||||
* `openidea-server` and real toolchains. Not part of `pnpm test`: they need the server
|
||||
* built (`cargo build -p ide-host --bin openidea-server`) and each language's tools.
|
||||
*/
|
||||
import { defineConfig, mergeConfig } from "vitest/config";
|
||||
import viteConfig from "../vite.config.ts";
|
||||
|
||||
export default mergeConfig(
|
||||
viteConfig({ command: "serve", mode: "test" }),
|
||||
defineConfig({
|
||||
test: {
|
||||
include: ["smoke/**/*.test.ts"],
|
||||
testTimeout: 10 * 60_000,
|
||||
hookTimeout: 5 * 60_000,
|
||||
// Each file starts a server and toolchains of its own.
|
||||
fileParallelism: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -343,6 +343,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
run::run_close_stdin,
|
||||
run::run_resize,
|
||||
run::run_gradle_jvm_args_script,
|
||||
run::run_gradle_init_script,
|
||||
run::run_tomcat_home,
|
||||
run::run_tomcat_artifacts,
|
||||
run::run_tomcat_build,
|
||||
@@ -386,6 +387,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
tools::jdk_download,
|
||||
sdk::sdk_detect,
|
||||
sdk::sdk_python_effective,
|
||||
sdk::sdk_php_language_level,
|
||||
sdk::sdk_project_libraries,
|
||||
sdk::sdk_locate_tools,
|
||||
sdk::sdk_python_create_venv,
|
||||
|
||||
@@ -189,6 +189,18 @@ pub async fn run_gradle_jvm_args_script(
|
||||
ide_host::ops::run::run_gradle_jvm_args_script(&state.host, window.label(), jvm_args).await
|
||||
}
|
||||
|
||||
/// Writes one of the IDE's Gradle init scripts where the build runs; returns its path.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn run_gradle_init_script(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
name: String,
|
||||
text: String,
|
||||
) -> IdeResult<String> {
|
||||
ide_host::ops::run::run_gradle_init_script(&state.host, window.label(), name, text).await
|
||||
}
|
||||
|
||||
/// Results from the JUnit XML reports (Surefire, Failsafe, Gradle) of the module at
|
||||
/// `module` (relative to the project root) written since `since_ms` (Unix epoch, ms).
|
||||
#[tauri::command]
|
||||
|
||||
@@ -24,6 +24,17 @@ pub fn sdk_python_effective(window: Window, state: State<'_, AppState>) -> IdeRe
|
||||
ide_host::ops::sdk::sdk_python_effective(&state.host, window.label())
|
||||
}
|
||||
|
||||
/// The PHP language level of the project `path` belongs to: the effective one and Auto's.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn sdk_php_language_level(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
path: PathBuf,
|
||||
) -> IdeResult<ide_host::ops::sdk::PhpLanguageLevels> {
|
||||
ide_host::ops::sdk::sdk_php_language_level(&state.host, window.label(), path).await
|
||||
}
|
||||
|
||||
/// SDK / library source folders shown under "External Libraries" in the Project view,
|
||||
/// with the Maven / Gradle dependency jars of the build model.
|
||||
#[tauri::command]
|
||||
|
||||
+46
-1
@@ -630,6 +630,8 @@ export const commands = {
|
||||
runResize: (id: RunId, cols: number, rows: number) => typedError<null, IdeError>(__TAURI_INVOKE("run_resize", { id, cols, rows })),
|
||||
/** Writes the Gradle init script adding `jvm_args` to the JVMs the build forks; returns its path. */
|
||||
runGradleJvmArgsScript: (jvmArgs: string[]) => typedError<string, IdeError>(__TAURI_INVOKE("run_gradle_jvm_args_script", { jvmArgs })),
|
||||
/** Writes one of the IDE's Gradle init scripts where the build runs; returns its path. */
|
||||
runGradleInitScript: (name: string, text: string) => typedError<string, IdeError>(__TAURI_INVOKE("run_gradle_init_script", { name, text })),
|
||||
runTomcatHome: (home: string) => typedError<TomcatHome, IdeError>(__TAURI_INVOKE("run_tomcat_home", { home })),
|
||||
runTomcatArtifacts: () => typedError<WebArtifact[], IdeError>(__TAURI_INVOKE("run_tomcat_artifacts")),
|
||||
runTomcatBuild: (config: RunConfig_Deserialize) => typedError<({
|
||||
@@ -748,6 +750,8 @@ export const commands = {
|
||||
sdkDetect: (kind: SdkKind) => typedError<Sdk[], IdeError>(__TAURI_INVOKE("sdk_detect", { kind })),
|
||||
/** The interpreter the project uses: the configured one, else its virtualenv. */
|
||||
sdkPythonEffective: () => typedError<string | null, IdeError>(__TAURI_INVOKE("sdk_python_effective")),
|
||||
/** The PHP language level of the project `path` belongs to: the effective one and Auto's. */
|
||||
sdkPhpLanguageLevel: (path: string) => typedError<PhpLanguageLevels, IdeError>(__TAURI_INVOKE("sdk_php_language_level", { path })),
|
||||
/**
|
||||
* SDK / library source folders shown under "External Libraries" in the Project view,
|
||||
* with the Maven / Gradle dependency jars of the build model.
|
||||
@@ -5390,6 +5394,37 @@ export type PathCompletion = {
|
||||
dir: boolean,
|
||||
};
|
||||
|
||||
/** A PHP version and where it comes from. */
|
||||
export type PhpLanguageLevel = {
|
||||
/** `X.Y.Z`. */
|
||||
version: string,
|
||||
source: PhpLevelSource,
|
||||
};
|
||||
|
||||
/**
|
||||
* The PHP language level of the project `path` belongs to (a workspace member, by path, as
|
||||
* the language server configuration picks it).
|
||||
*/
|
||||
export type PhpLanguageLevels = {
|
||||
/** The one Intelephense checks against; `None`: its default. */
|
||||
effective: PhpLanguageLevel | null,
|
||||
/** The one Auto picks, from `composer.json`. */
|
||||
auto: PhpLanguageLevel | null,
|
||||
/**
|
||||
* The workspace member `path` belongs to: its own settings choose the level, not the
|
||||
* workspace's. `None`: the opened project.
|
||||
*/
|
||||
member: IpcPath | null,
|
||||
};
|
||||
|
||||
export type PhpLevelSource =
|
||||
/** `languages.php.languageLevel`. */
|
||||
"settings" |
|
||||
/** `config.platform.php` of `composer.json`. */
|
||||
"platform" |
|
||||
/** The lowest version `require.php` of `composer.json` allows. */
|
||||
"require";
|
||||
|
||||
export type PhpSettings = {
|
||||
/** `php` CLI executable; empty = the one on `PATH`. */
|
||||
interpreter?: string,
|
||||
@@ -5398,6 +5433,16 @@ export type PhpSettings = {
|
||||
* `composer.json` (`config.platform.php`, else the lowest version `require.php` allows).
|
||||
*/
|
||||
languageLevel?: string,
|
||||
/**
|
||||
* Folders outside the project whose code the language server indexes too (a platform a
|
||||
* module extends); absolute, `~/`, or relative to the project.
|
||||
*/
|
||||
includePaths?: string[],
|
||||
/**
|
||||
* Intelephense stub sets loaded on top of its defaults and the `ext-*` entries of
|
||||
* `composer.json` (`wordpress`).
|
||||
*/
|
||||
stubs?: string[],
|
||||
};
|
||||
|
||||
/** The focused editor when a plugin command is invoked. */
|
||||
@@ -5435,7 +5480,7 @@ export type PluginEvent =
|
||||
/** For the script of a view's `html` body. */
|
||||
{ type: "viewMessage"; pluginId: string; view: string; message: string } |
|
||||
/** Open a terminal running `program` (already approved and verified). */
|
||||
{ type: "openTerminal"; pluginId: string; title: string; program: IpcPath; args: string[]; cwd: IpcPath } |
|
||||
{ type: "openTerminal"; pluginId: string; title: string; program: IpcPath; args: string[]; cwd: IpcPath } | { type: "openLocation"; pluginId: string; path: IpcPath; line: number; column: number } |
|
||||
/**
|
||||
* Plugin languages, or the servers they may start, changed: re-fetch
|
||||
* `languages` (the host re-detects open documents: [`crate::Host::plugin_languages_changed`]).
|
||||
|
||||
@@ -44,6 +44,18 @@ describe("semanticRanges", () => {
|
||||
expect(semanticRanges(doc, input)).toEqual([{ from: 0, to: 3, className: "cm-sem-function" }]);
|
||||
});
|
||||
|
||||
it("marks globals (lua-language-server's `global` modifier)", () => {
|
||||
const input = {
|
||||
tokenTypes: ["variable", "function"],
|
||||
tokenModifiers: ["global"],
|
||||
data: [0, 0, 3, 0, 1, 0, 4, 3, 1, 1],
|
||||
};
|
||||
expect(semanticRanges(doc, input)).toEqual([
|
||||
{ from: 0, to: 3, className: "cm-sem-global" },
|
||||
{ from: 4, to: 7, className: "cm-sem-function cm-sem-global" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("colours a constructor (jdtls: class + `constructor`) as a function", () => {
|
||||
const input = { tokenTypes: ["class"], tokenModifiers: ["constructor", "declaration"], data: [0, 0, 3, 0, 0b11] };
|
||||
expect(semanticRanges(doc, input)[0]?.className).toBe("cm-sem-function cm-sem-declaration");
|
||||
|
||||
@@ -56,6 +56,7 @@ const MODIFIER_CLASS: Record<string, string> = {
|
||||
declaration: "declaration",
|
||||
definition: "declaration",
|
||||
defaultLibrary: "builtin",
|
||||
global: "global",
|
||||
};
|
||||
|
||||
/** Number of `--syn-rainbow-<n>` colours (IDEA's five semantic highlighting colours). */
|
||||
|
||||
@@ -361,6 +361,8 @@ export const lspTheme = EditorView.theme({
|
||||
".cm-sem-parameter": { color: "var(--syn-param, var(--syn-variable))" },
|
||||
".cm-sem-self": { color: "var(--syn-self, var(--syn-keyword))" },
|
||||
".cm-sem-keyword": { color: "var(--syn-keyword)", fontWeight: "var(--syn-keyword-bold, inherit)" },
|
||||
// Before the builtin rule: library globals (`print`, `string`) keep the predefined colour.
|
||||
".cm-sem-global:not(.cm-sem-function)": { color: "var(--syn-global, inherit)" },
|
||||
".cm-sem-builtin:not(.cm-sem-function)": { color: "var(--syn-builtin, inherit)" },
|
||||
".cm-sem-static": { fontStyle: "var(--syn-static-italic, italic)" },
|
||||
".cm-sem-python-static": { fontStyle: "var(--syn-python-static-italic, normal)" },
|
||||
|
||||
@@ -79,6 +79,27 @@ describe("run config → launch config", () => {
|
||||
expect(extra(globalPytest).python).toBe("python3");
|
||||
});
|
||||
|
||||
it("debugpy: uv / poetry run the debuggee in the project's environment", () => {
|
||||
const debug = (command: string, args: string[]) =>
|
||||
launchConfigFor(rc({ kind: "python", command, args, debugAdapter: "debugpy" }), "/p")!;
|
||||
|
||||
const script = debug("uv", ["run", "python", "main.py", "-v"]);
|
||||
expect(script.program).toBe("/p/main.py");
|
||||
expect(script.args).toEqual(["-v"]);
|
||||
expect(extra(script)).toMatchObject({ python: ["uv", "run", "python"], debugLauncherPython: "python3" });
|
||||
expect(extra(script).module).toBeUndefined();
|
||||
|
||||
const mod = debug("poetry", ["run", "python3", "-m", "app"]);
|
||||
expect(extra(mod)).toMatchObject({ module: "app", python: ["poetry", "run", "python3"] });
|
||||
|
||||
const tool = debug("uv", ["run", "pytest", "-x"]);
|
||||
expect(extra(tool)).toMatchObject({ module: "pytest", python: ["uv", "run", "python"] });
|
||||
expect(tool.args).toEqual(["-x"]);
|
||||
|
||||
const django = debug("pdm", ["run", "python", "manage.py", "runserver"]);
|
||||
expect(extra(django)).toMatchObject({ django: true, python: ["pdm", "run", "python"] });
|
||||
});
|
||||
|
||||
it("js-debug: npm scripts run through the package manager, node scripts directly", () => {
|
||||
const npm = launchConfigFor(
|
||||
rc({ kind: "npm", command: "pnpm", args: ["run", "dev"], cwd: "web", debugAdapter: "js-debug" }),
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import { useProject } from "@/platform/project";
|
||||
import type { LaunchConfig } from "@/platform/ipc";
|
||||
import {
|
||||
provideNotificationService,
|
||||
provideToolWindowService,
|
||||
type ToolWindowService,
|
||||
} from "@/workbench/contributions";
|
||||
import { adoptSession, handleDebugEvent, startSession, useDebug } from "../state";
|
||||
|
||||
const ipc = vi.hoisted(() => ({
|
||||
debugStart: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/platform/ipc", async (original) => {
|
||||
const real = await original<typeof import("@/platform/ipc")>();
|
||||
return { ...real, isTauri: () => true, commands: { ...real.commands, ...ipc } };
|
||||
});
|
||||
|
||||
const attach: LaunchConfig = {
|
||||
name: "AppKt",
|
||||
adapter: "kotlin-debug-adapter",
|
||||
request: "attach",
|
||||
program: null,
|
||||
args: [],
|
||||
cwd: "/p",
|
||||
env: {},
|
||||
extraJson: JSON.stringify({ hostName: "127.0.0.1", port: 35549 }),
|
||||
};
|
||||
const notify = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
useDebug.setState({ sessions: {}, active: null, console: {} });
|
||||
useProject.setState({ project: { root: "/p" } as never });
|
||||
provideNotificationService({ notify });
|
||||
provideToolWindowService({ show: vi.fn() } as unknown as ToolWindowService);
|
||||
vi.clearAllMocks();
|
||||
// The adapter's session announces itself, then the attach fails: the JVM is gone.
|
||||
ipc.debugStart.mockImplementation(async () => {
|
||||
handleDebugEvent(43, { kind: "started", name: "AppKt", adapter: "kotlin-debug-adapter", parent: null });
|
||||
return { status: "error", error: { kind: "internal", message: "attach failed: Internal error.", data: null } };
|
||||
});
|
||||
});
|
||||
|
||||
/** A finished session that debugged a JVM of run 7. */
|
||||
function finished(endedAgoMs: number) {
|
||||
adoptSession(1, { name: "AppKt", process: 7 });
|
||||
handleDebugEvent(1, { kind: "terminated" });
|
||||
useDebug.setState((s) => ({
|
||||
sessions: { ...s.sessions, 1: { ...s.sessions[1]!, endedAt: Date.now() - endedAgoMs } },
|
||||
}));
|
||||
}
|
||||
|
||||
it("ignores a failed attach to the port a JVM announced as its debugger detached", async () => {
|
||||
finished(100);
|
||||
expect(await startSession(attach, { process: 7, rerunnable: false })).toBeNull();
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
const { sessions, active } = useDebug.getState();
|
||||
expect(Object.keys(sessions)).toEqual(["1"]);
|
||||
expect(active).toBe(1);
|
||||
});
|
||||
|
||||
it("reports a failed attach to a JVM of another run, or long after the last session ended", async () => {
|
||||
finished(100);
|
||||
await startSession(attach, { process: 8, rerunnable: false });
|
||||
expect(notify).toHaveBeenCalledWith(expect.objectContaining({ title: "Cannot start debugger for AppKt" }));
|
||||
|
||||
notify.mockClear();
|
||||
useDebug.setState({ sessions: {}, active: null, console: {} });
|
||||
finished(60_000);
|
||||
await startSession(attach, { process: 7, rerunnable: false });
|
||||
expect(notify).toHaveBeenCalledWith(expect.objectContaining({ title: "Cannot start debugger for AppKt" }));
|
||||
expect(useDebug.getState().sessions[1]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("replaces the finished session once an attach soon after it succeeds", async () => {
|
||||
finished(100);
|
||||
ipc.debugStart.mockResolvedValueOnce({ status: "ok", data: 44 });
|
||||
expect(await startSession(attach, { process: 7, rerunnable: false })).toBe(44);
|
||||
expect(Object.keys(useDebug.getState().sessions)).toEqual(["44"]);
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -58,15 +58,20 @@ function cargoLaunch(rc: RunConfig, cwd: string, adapter: string): LaunchConfig
|
||||
};
|
||||
}
|
||||
|
||||
const PYTHON_NAME = /^python[\d.]*(\.exe)?$/;
|
||||
|
||||
function pythonFor(rc: RunConfig, cwd: string): string {
|
||||
const cmd = rc.command;
|
||||
const name = cmd.split("/").pop() ?? cmd;
|
||||
if (/^python[\d.]*(\.exe)?$/.test(name)) return resolveCommand(cmd, cwd);
|
||||
if (PYTHON_NAME.test(name)) return resolveCommand(cmd, cwd);
|
||||
// A venv tool such as `.venv/bin/pytest`: use the interpreter next to it.
|
||||
if (cmd.includes("/")) return resolveCommand(cmd.slice(0, cmd.lastIndexOf("/") + 1) + "python", cwd);
|
||||
return "python3";
|
||||
}
|
||||
|
||||
/** Project managers whose `run` executes in the project's environment (`uv run python …`). */
|
||||
const PYTHON_RUNNERS = new Set(["uv", "poetry", "pdm", "pipenv", "hatch", "rye"]);
|
||||
|
||||
function debugpyLaunch(rc: RunConfig, cwd: string): LaunchConfig {
|
||||
const out = base(rc, "debugpy", cwd);
|
||||
const dbg = workflowSettings().debugger;
|
||||
@@ -78,19 +83,29 @@ function debugpyLaunch(rc: RunConfig, cwd: string): LaunchConfig {
|
||||
redirectOutput: true,
|
||||
};
|
||||
if (dbg.python.gevent) extra.gevent = true;
|
||||
const name = rc.command.split("/").pop() ?? rc.command;
|
||||
const isPython = /^python[\d.]*(\.exe)?$/.test(name);
|
||||
if (!isPython) {
|
||||
const name = (rc.command.split("/").pop() ?? rc.command).replace(/\.exe$/, "");
|
||||
let args = rc.args;
|
||||
let tool: string | null = PYTHON_NAME.test(name) ? null : name;
|
||||
if (PYTHON_RUNNERS.has(name) && args[0] === "run" && args[1]) {
|
||||
// The debuggee runs through the manager (`uv run python <debugpy> …`) to get the project's
|
||||
// environment; the adapter and its launcher need a plain interpreter.
|
||||
const python = PYTHON_NAME.test(args[1]) ? args[1] : "python";
|
||||
extra.python = [resolveCommand(rc.command, cwd), "run", python];
|
||||
extra.debugLauncherPython = "python3";
|
||||
tool = PYTHON_NAME.test(args[1]) ? null : args[1];
|
||||
args = args.slice(2);
|
||||
}
|
||||
if (tool) {
|
||||
// A console script (`pytest`, `uvicorn`, …): run it as a module.
|
||||
extra.module = name.replace(/\.exe$/, "");
|
||||
out.args = [...rc.args];
|
||||
} else if (rc.args[0] === "-m" && rc.args[1]) {
|
||||
extra.module = rc.args[1];
|
||||
out.args = rc.args.slice(2);
|
||||
} else if (rc.args[0]) {
|
||||
out.program = join(cwd, rc.args[0]);
|
||||
out.args = rc.args.slice(1);
|
||||
if (rc.args[0].endsWith("manage.py")) extra.django = true;
|
||||
extra.module = tool;
|
||||
out.args = [...args];
|
||||
} else if (args[0] === "-m" && args[1]) {
|
||||
extra.module = args[1];
|
||||
out.args = args.slice(2);
|
||||
} else if (args[0]) {
|
||||
out.program = join(cwd, args[0]);
|
||||
out.args = args.slice(1);
|
||||
if (args[0].endsWith("manage.py")) extra.django = true;
|
||||
}
|
||||
out.extraJson = JSON.stringify(extra);
|
||||
return out;
|
||||
|
||||
@@ -38,6 +38,8 @@ export interface DebugSession {
|
||||
/** Originating configuration, retained for toolbar targeting and prepared debug reruns. */
|
||||
runConfig?: RunConfig;
|
||||
startedAt?: number;
|
||||
/** When it terminated. */
|
||||
endedAt?: number;
|
||||
adapter: string;
|
||||
parent: DebugSessionId | null;
|
||||
/** Launch configuration (root sessions), used by Rerun. */
|
||||
@@ -223,16 +225,58 @@ function ownSession(id: DebugSessionId, event: DebugEvent): DebugSession | null
|
||||
return event.kind === "started" ? adoptSession(id, { name: event.name, adapter: event.adapter }) : adoptSession(id);
|
||||
}
|
||||
|
||||
/** A start finished (`id` on success): sessions no start claimed are failed starts' leftovers. */
|
||||
function settleStart(id: DebugSessionId | null) {
|
||||
/**
|
||||
* A start finished (`id` on success): sessions no start claimed are failed starts' leftovers,
|
||||
* kept as terminated or, with `discard`, removed.
|
||||
*/
|
||||
function settleStart(id: DebugSessionId | null, discard = false) {
|
||||
pendingStarts = Math.max(0, pendingStarts - 1);
|
||||
if (id != null) provisional.delete(id);
|
||||
if (pendingStarts > 0) return;
|
||||
const leftovers = [...provisional];
|
||||
provisional.clear();
|
||||
if (discard) {
|
||||
useDebug.setState((st) => {
|
||||
const sessions = { ...st.sessions };
|
||||
const console = { ...st.console };
|
||||
for (const s of leftovers) {
|
||||
delete sessions[s];
|
||||
delete console[s];
|
||||
ansiStreams.delete(s);
|
||||
}
|
||||
return { sessions, console, active: st.active != null && sessions[st.active] ? st.active : null };
|
||||
});
|
||||
publishContext();
|
||||
return;
|
||||
}
|
||||
for (const s of leftovers) if (isLive(useDebug.getState().sessions[s])) markTerminated(s);
|
||||
}
|
||||
|
||||
/** Drops finished sessions; running ones stay (multiple concurrent sessions are fine). */
|
||||
function dropFinished() {
|
||||
useDebug.setState((s) => {
|
||||
const sessions = Object.fromEntries(Object.entries(s.sessions).filter(([, v]) => isLive(v)));
|
||||
const consoleLines = Object.fromEntries(Object.entries(s.console).filter(([k]) => sessions[+k]));
|
||||
for (const id of ansiStreams.keys()) if (!sessions[id]) ansiStreams.delete(id);
|
||||
return { sessions, console: consoleLines, active: s.active != null && sessions[s.active] ? s.active : null };
|
||||
});
|
||||
publishContext();
|
||||
}
|
||||
|
||||
/**
|
||||
* A JDWP agent in server mode listens again, on a new port, when its debugger detaches; a
|
||||
* debugger that detaches as the JVM exits leaves an announcement of a JVM about to be gone.
|
||||
* An attach to `process` this soon after a session on it ended may be to such a JVM.
|
||||
*/
|
||||
const RELISTEN_MS = 3000;
|
||||
function justDetached(process: RunId | null | undefined): boolean {
|
||||
if (process == null) return false;
|
||||
const now = Date.now();
|
||||
return Object.values(useDebug.getState().sessions).some(
|
||||
(s) => s.process === process && s.status === "terminated" && s.endedAt != null && now - s.endedAt < RELISTEN_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export function publishContext() {
|
||||
const s = activeSession();
|
||||
const retained = Object.values(useDebug.getState().sessions);
|
||||
@@ -460,14 +504,10 @@ export async function startSession(
|
||||
notifications().notify({ group: "run", level: "warning", title: "Debugging needs the desktop app" });
|
||||
return null;
|
||||
}
|
||||
// Drop finished sessions; keep running ones (multiple concurrent sessions are fine).
|
||||
useDebug.setState((s) => {
|
||||
const sessions = Object.fromEntries(Object.entries(s.sessions).filter(([, v]) => isLive(v)));
|
||||
const consoleLines = Object.fromEntries(Object.entries(s.console).filter(([k]) => sessions[+k]));
|
||||
for (const id of ansiStreams.keys()) if (!sessions[id]) ansiStreams.delete(id);
|
||||
return { sessions, console: consoleLines, active: s.active != null && sessions[s.active] ? s.active : null };
|
||||
});
|
||||
publishContext();
|
||||
// Attaching to a JVM that may be gone: the finished session stays until the attach succeeds.
|
||||
const afterDetach = justDetached(options.process);
|
||||
const previousActive = useDebug.getState().active;
|
||||
if (!afterDetach) dropFinished();
|
||||
try {
|
||||
if (workflowSettings().run.showOnStart) toolWindowService().show("debug");
|
||||
} catch {
|
||||
@@ -482,6 +522,7 @@ export async function startSession(
|
||||
const id = await call(commands.debugStart(launch));
|
||||
console.info("Debug adapter started", { name: launch.name, ms: performance.now() - adapterStarted });
|
||||
owned.add(id);
|
||||
if (afterDetach) dropFinished();
|
||||
if (options.preparation?.signal.aborted) {
|
||||
await call(commands.debugStop(id)).catch(() => undefined);
|
||||
settleStart(id);
|
||||
@@ -526,7 +567,16 @@ export async function startSession(
|
||||
void syncSpecial();
|
||||
return id;
|
||||
} catch (e) {
|
||||
settleStart(null);
|
||||
settleStart(null, afterDetach);
|
||||
if (afterDetach) {
|
||||
// The JVM that announced the port ended as its previous debugger detached.
|
||||
console.info("Debugger not attached: the JVM is gone", { name: launch.name, error: e });
|
||||
const { sessions, active } = useDebug.getState();
|
||||
if (active == null && previousActive != null && sessions[previousActive])
|
||||
useDebug.setState({ active: previousActive });
|
||||
publishContext();
|
||||
return null;
|
||||
}
|
||||
if (options.preparation?.signal.aborted || isCancelled(e)) return null;
|
||||
if (options.preparation)
|
||||
appendConsole(options.preparation.id, "stderr", (e instanceof Error ? e.message : String(e)) + "\n");
|
||||
@@ -612,7 +662,7 @@ export function sessionSupports(cap: keyof DebugCapabilities, s: DebugSession |
|
||||
}
|
||||
|
||||
function markTerminated(id: DebugSessionId) {
|
||||
patch(id, { status: "terminated", frames: [], selectedFrame: null, stoppedThread: null });
|
||||
patch(id, { status: "terminated", endedAt: Date.now(), frames: [], selectedFrame: null, stoppedThread: null });
|
||||
const st = useDebug.getState();
|
||||
if (st.active === id) {
|
||||
// Prefer another live session (e.g. the parent of a finished child).
|
||||
|
||||
@@ -130,6 +130,7 @@ export const ICLS_ATTRIBUTES: Record<string, Array<[Part, string]>> = {
|
||||
DEFAULT_IDENTIFIER: [["FOREGROUND", "--syn-variable"]],
|
||||
DEFAULT_LOCAL_VARIABLE: [["FOREGROUND", "--syn-variable"]],
|
||||
DEFAULT_PARAMETER: [["FOREGROUND", "--syn-param"]],
|
||||
DEFAULT_GLOBAL_VARIABLE: [["FOREGROUND", "--syn-global"]],
|
||||
DEFAULT_PREDEFINED_SYMBOL: [["FOREGROUND", "--syn-builtin"]],
|
||||
DEFAULT_METADATA: [
|
||||
["FOREGROUND", "--syn-annotation"],
|
||||
|
||||
@@ -12,7 +12,7 @@ import { onIdeEvent } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { BarButton } from "@/ui/BarButton";
|
||||
import { registerStatusItem } from "@/workbench/contributions";
|
||||
import { maybeEditorService, registerStatusItem } from "@/workbench/contributions";
|
||||
import { removeTask, upsertTask, useTasks } from "@/workbench/tasks";
|
||||
import { pluginsBackend } from "./backend";
|
||||
import { bumpView, deliverViewMessage } from "./views/viewStore";
|
||||
@@ -259,6 +259,9 @@ export function handleRuntimeEvent(e: PluginEvent): boolean {
|
||||
// The program was approved (`process:`) and verified by the host.
|
||||
void executeAction("terminal.runCommand", { program: e.program, args: e.args, name: e.title, cwd: e.cwd });
|
||||
return true;
|
||||
case "openLocation":
|
||||
void maybeEditorService()?.openFile(e.path, { selection: { line: e.line, col: e.column }, focus: true });
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -290,6 +290,49 @@ function projectFileIcon(pluginId: string, decl: FileIconDecl): () => void {
|
||||
|
||||
export const pluginToolWindowId = (pluginId: string, view: string) => `plugin.${pluginId}.${view}`;
|
||||
|
||||
function registerView(pluginId: string, decl: ViewDecl, icon: string): () => void {
|
||||
return registerToolWindow({
|
||||
id: pluginToolWindowId(pluginId, decl.id),
|
||||
title: decl.title,
|
||||
icon,
|
||||
anchor: decl.location,
|
||||
order: 900,
|
||||
component: () =>
|
||||
decl.web ? (
|
||||
<WebView pluginId={pluginId} view={decl.id} label={decl.title} />
|
||||
) : (
|
||||
<PluginView pluginId={pluginId} view={decl.id} label={decl.title} />
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function projectView(pluginId: string, decl: ViewDecl): () => void {
|
||||
if (!decl.icon?.endsWith(".svg")) return registerView(pluginId, decl, decl.icon ?? "plugin");
|
||||
let disposed = false;
|
||||
let undo = () => {};
|
||||
void pluginsBackend()
|
||||
.assets(pluginId, `viewIcon/${decl.id}`)
|
||||
.then((assets) => {
|
||||
if (disposed) return;
|
||||
const svg = assets.find(([id]) => id === `viewIcon/${decl.id}`)?.[1];
|
||||
const name = `ext:${pluginId}/view-${decl.id}`;
|
||||
const unregister = svg ? registerExternalIcons([[name, svg]]) : () => {};
|
||||
const remove = registerView(pluginId, decl, svg ? name : "plugin");
|
||||
undo = () => {
|
||||
remove();
|
||||
unregister();
|
||||
};
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
console.warn(`plugin ${pluginId}: view icon ${decl.id}`, e);
|
||||
if (!disposed) undo = registerView(pluginId, decl, "plugin");
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
undo();
|
||||
};
|
||||
}
|
||||
|
||||
/** Registers the client side of the P7 points; returns their disposer. */
|
||||
export function defineViewUiPoints(): () => void {
|
||||
const disposers = [
|
||||
@@ -326,20 +369,7 @@ export function defineViewUiPoints(): () => void {
|
||||
}),
|
||||
defineUiPoint<ViewDecl>("views", {
|
||||
title: "Tool Windows",
|
||||
project: ({ pluginId, decl }) =>
|
||||
registerToolWindow({
|
||||
id: pluginToolWindowId(pluginId, decl.id),
|
||||
title: decl.title,
|
||||
icon: decl.icon ?? "plugin",
|
||||
anchor: decl.location,
|
||||
order: 900,
|
||||
component: () =>
|
||||
decl.web ? (
|
||||
<WebView pluginId={pluginId} view={decl.id} label={decl.title} />
|
||||
) : (
|
||||
<PluginView pluginId={pluginId} view={decl.id} label={decl.title} />
|
||||
),
|
||||
}),
|
||||
project: ({ pluginId, decl }) => projectView(pluginId, decl),
|
||||
}),
|
||||
defineUiPoint<CustomEditorDecl>("customEditors", {
|
||||
title: "Editors",
|
||||
|
||||
@@ -256,6 +256,37 @@ describe("context configuration", () => {
|
||||
expect(contextConfig("docs/readme.md", configs)).toBeNull();
|
||||
});
|
||||
|
||||
it("runs a module that imports relatively as a module of its package", () => {
|
||||
const cli = "from .models import Priority, TaskList\n\n\ndef main():\n pass\n";
|
||||
expect(contextConfig("src/tasks/cli.py", configs, cli)).toMatchObject({
|
||||
kind: "python",
|
||||
command: "../.venv/bin/python",
|
||||
args: ["-m", "tasks.cli"],
|
||||
cwd: "src",
|
||||
debugAdapter: "debugpy",
|
||||
});
|
||||
expect(contextConfig("app/api/routes.py", [], "from . import models\n")).toMatchObject({
|
||||
command: "python3",
|
||||
args: ["-m", "app.api.routes"],
|
||||
cwd: null,
|
||||
});
|
||||
expect(contextConfig("src/app/__main__.py", [], "from .cli import main\n")).toMatchObject({
|
||||
args: ["-m", "app"],
|
||||
cwd: "src",
|
||||
});
|
||||
// Inside a nested Python project: from its folder (the venv command stays relative to the root).
|
||||
const nested = [cfg({ id: "pytest", kind: "pytest", command: "backend/.venv/bin/pytest", cwd: "backend" })];
|
||||
expect(contextConfig("backend/app/jobs.py", nested, "from .db import session\n")).toMatchObject({
|
||||
command: "../backend/.venv/bin/python",
|
||||
args: ["-m", "app.jobs"],
|
||||
cwd: "backend",
|
||||
});
|
||||
// Absolute imports, a top-level file, a folder that can't be a package: as a script.
|
||||
expect(contextConfig("src/tasks/cli.py", configs, "import os\n")!.args).toEqual(["src/tasks/cli.py"]);
|
||||
expect(contextConfig("tool.py", [], "from .x import y\n")!.args).toEqual(["tool.py"]);
|
||||
expect(contextConfig("my-scripts/run.py", [], "from .x import y\n")!.args).toEqual(["my-scripts/run.py"]);
|
||||
});
|
||||
|
||||
it("runs pytest for a test file even without a detected pytest config", () => {
|
||||
expect(contextConfig("worker/test_worker.py", [])).toMatchObject({
|
||||
command: "python3",
|
||||
|
||||
@@ -236,7 +236,48 @@ function mainConfigFor(relPath: string, configs: RunConfig[]): RunConfig | null
|
||||
return null;
|
||||
}
|
||||
|
||||
function scriptConfigFor(relPath: string, configs: RunConfig[], windows = isWindows()): RunConfig | null {
|
||||
/** `from .models import …`, `from . import x`: the file is a module of a package. */
|
||||
const RELATIVE_IMPORT = /^[ \t]*from[ \t]+\.+[\w.]*[ \t]+import\b/m;
|
||||
const IDENTIFIER = /^[A-Za-z_]\w*$/;
|
||||
|
||||
/**
|
||||
* A module that imports relatively runs as `python -m <package>.<module>` from the folder its top
|
||||
* package sits in (the last `src` of its path, else the Python project's folder): run as a script,
|
||||
* its relative imports fail. `null` for other files.
|
||||
*/
|
||||
export function pythonModuleRun(
|
||||
relPath: string,
|
||||
content: string | undefined,
|
||||
configs: RunConfig[],
|
||||
): { cwd: string; module: string } | null {
|
||||
if (!content || !RELATIVE_IMPORT.test(content)) return null;
|
||||
const project = closest(
|
||||
configs.filter((c) => c.kind === "python" || c.kind === "pytest"),
|
||||
relPath,
|
||||
)?.cwd?.replace(/^\.\/?|\/+$/g, "");
|
||||
const segs = relPath.replace(/\.py$/, "").split("/");
|
||||
const first = project && relPath.startsWith(`${project}/`) ? project.split("/").length : 0;
|
||||
const src = segs.lastIndexOf("src", segs.length - 2);
|
||||
const start = src >= first ? src + 1 : first;
|
||||
const parts = segs.slice(start);
|
||||
// Only a module inside a package can import relatively.
|
||||
if (parts.length < 2 || !parts.every((p) => IDENTIFIER.test(p))) return null;
|
||||
if (parts.at(-1) === "__main__" || parts.at(-1) === "__init__") parts.pop();
|
||||
return { cwd: segs.slice(0, start).join("/"), module: parts.join(".") };
|
||||
}
|
||||
|
||||
/** A command relative to the project root (`.venv/bin/python`), from `cwd` inside it. */
|
||||
function fromSubfolder(command: string, cwd: string): string {
|
||||
if (!cwd || !command.includes("/") || command.startsWith("/") || /^[A-Za-z]:/.test(command)) return command;
|
||||
return "../".repeat(cwd.split("/").length) + command;
|
||||
}
|
||||
|
||||
function scriptConfigFor(
|
||||
relPath: string,
|
||||
configs: RunConfig[],
|
||||
windows = isWindows(),
|
||||
content?: string,
|
||||
): RunConfig | null {
|
||||
const base: RunConfig = {
|
||||
id: `context:file:${relPath}`,
|
||||
name: relPath.split("/").pop() ?? relPath,
|
||||
@@ -251,10 +292,18 @@ function scriptConfigFor(relPath: string, configs: RunConfig[], windows = isWind
|
||||
};
|
||||
if (relPath.endsWith(".py")) {
|
||||
// Reuse the project's interpreter (venv / uv / poetry) when detected.
|
||||
const py = projectPython(configs);
|
||||
if (py)
|
||||
return { ...base, kind: "python", command: py.command, args: [...py.prefix, relPath], debugAdapter: "debugpy" };
|
||||
return { ...base, kind: "python", command: "python3", args: [relPath], debugAdapter: "debugpy" };
|
||||
const py = projectPython(configs) ?? { command: "python3", prefix: [] };
|
||||
const module = pythonModuleRun(relPath, content, configs);
|
||||
if (module)
|
||||
return {
|
||||
...base,
|
||||
kind: "python",
|
||||
command: fromSubfolder(py.command, module.cwd),
|
||||
args: [...py.prefix, "-m", module.module],
|
||||
cwd: module.cwd || null,
|
||||
debugAdapter: "debugpy",
|
||||
};
|
||||
return { ...base, kind: "python", command: py.command, args: [...py.prefix, relPath], debugAdapter: "debugpy" };
|
||||
}
|
||||
if (/\.[cm]?js$/.test(relPath)) return { ...base, command: "node", args: [relPath], debugAdapter: "js-debug" };
|
||||
if (/\.ts$/.test(relPath)) return { ...base, command: "npx", args: ["tsx", relPath], debugAdapter: "js-debug" };
|
||||
@@ -278,7 +327,7 @@ export function contextConfig(
|
||||
): RunConfig | null {
|
||||
const role = fileRole(relPath, content, windows, hasDjangoTests(configs));
|
||||
if (role === "test") return testConfigFor(relPath, configs);
|
||||
if (role === "main") return mainConfigFor(relPath, configs) ?? scriptConfigFor(relPath, configs, windows);
|
||||
if (role === "script") return scriptConfigFor(relPath, configs, windows);
|
||||
if (role === "main") return mainConfigFor(relPath, configs) ?? scriptConfigFor(relPath, configs, windows, content);
|
||||
if (role === "script") return scriptConfigFor(relPath, configs, windows, content);
|
||||
return null;
|
||||
}
|
||||
@@ -85,18 +85,15 @@ export function delegatedGradleConfig(config: RunConfig, module: string, ctx: De
|
||||
};
|
||||
}
|
||||
|
||||
/** Writes an init script where Gradle can read it; it never changes, so once per session. */
|
||||
/**
|
||||
* Writes an init script where Gradle reads it: on the machine that runs the build (the
|
||||
* remote host in remote development). It never changes, so once per session.
|
||||
*/
|
||||
const scriptPaths = new Map<string, Promise<string>>();
|
||||
function writeScript(name: string, text: string): Promise<string> {
|
||||
let written = scriptPaths.get(name);
|
||||
if (!written) {
|
||||
written = (async () => {
|
||||
const { tempDir, join } = await import("@tauri-apps/api/path");
|
||||
const path = await join(await tempDir(), name);
|
||||
await call(commands.fsDelete(path)).catch(() => {});
|
||||
await call(commands.fsCreateFileBytes(path, Array.from(new TextEncoder().encode(text))));
|
||||
return path;
|
||||
})().catch((e) => {
|
||||
written = call(commands.runGradleInitScript(name, text)).catch((e) => {
|
||||
scriptPaths.delete(name);
|
||||
throw e;
|
||||
});
|
||||
@@ -121,7 +118,7 @@ export function withTestEvents<T extends Pick<RunConfig, "kind" | "command" | "a
|
||||
export async function gradleTestLaunch<T extends RunConfig>(config: T): Promise<T> {
|
||||
if (!isTauri() || !config.isTest || !isGradleCommand(config)) return config;
|
||||
try {
|
||||
return withTestEvents(config, await writeScript("openidea-gradle-tests.gradle", TESTS_SCRIPT));
|
||||
return withTestEvents(config, await writeScript("openidea-gradle-tests", TESTS_SCRIPT));
|
||||
} catch (e) {
|
||||
console.warn("Gradle test init script not written", e);
|
||||
return config;
|
||||
@@ -150,7 +147,7 @@ export async function gradleDelegation(config: RunConfig): Promise<RunConfig | n
|
||||
};
|
||||
if (!delegatedGradleConfig(config, module, ctx)) return null;
|
||||
const [script, configurationCache] = await Promise.all([
|
||||
writeScript("openidea-gradle-run.gradle", RUN_SCRIPT),
|
||||
writeScript("openidea-gradle-run", RUN_SCRIPT),
|
||||
supportsConfigurationCache(ctx),
|
||||
]);
|
||||
return delegatedGradleConfig(config, module, { ...ctx, script, configurationCache });
|
||||
|
||||
@@ -57,7 +57,15 @@ describe("languages settings", () => {
|
||||
});
|
||||
|
||||
it("defaults the PHP language level to Auto and keeps only known levels", () => {
|
||||
expect(validateSettings({}).languages.php).toEqual({ interpreter: "", languageLevel: "" });
|
||||
expect(validateSettings({}).languages.php).toEqual({
|
||||
interpreter: "",
|
||||
languageLevel: "",
|
||||
includePaths: [],
|
||||
stubs: [],
|
||||
});
|
||||
const lists = validateLanguages({ php: { includePaths: [" ../platform ", "", 3, "../platform"], stubs: "redis" } });
|
||||
expect(lists.php.includePaths).toEqual(["../platform"]);
|
||||
expect(lists.php.stubs).toEqual([]);
|
||||
expect(validateLanguages({ php: { languageLevel: "7.4" } }).php.languageLevel).toBe("7.4");
|
||||
for (const level of ["7.4.3", "9.0", 8.1, null]) {
|
||||
expect(validateLanguages({ php: { languageLevel: level } }).php.languageLevel).toBe("");
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { executeAction, registerAction } from "@/platform/actions";
|
||||
import { setContext } from "@/platform/context";
|
||||
import { useProject } from "@/platform/project";
|
||||
import type { PhpLanguageLevels, ProjectInfo, Settings } from "@/bindings";
|
||||
|
||||
type FsChange = { created: string[]; modified: string[]; removed: string[] };
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
notify: vi.fn(),
|
||||
level: vi.fn(),
|
||||
set: vi.fn(),
|
||||
fsChanged: undefined as ((e: FsChange) => void) | undefined,
|
||||
component: null as React.ComponentType | null,
|
||||
}));
|
||||
vi.mock("@/platform/ipc", () => ({
|
||||
call: (p: unknown) => p,
|
||||
isTauri: () => true,
|
||||
onIdeEvent: (name: string, handler: (e: FsChange) => void) => {
|
||||
if (name === "fsChanged") mocks.fsChanged = handler;
|
||||
return () => {};
|
||||
},
|
||||
commands: { sdkPhpLanguageLevel: mocks.level, settingsSet: mocks.set },
|
||||
}));
|
||||
vi.mock("@/workbench/contributions", () => ({
|
||||
notifications: () => ({ notify: mocks.notify }),
|
||||
registerStatusItem: (item: { component: React.ComponentType }) => {
|
||||
mocks.component = item.component;
|
||||
return () => {};
|
||||
},
|
||||
}));
|
||||
import { activatePhpStatus } from "../languages/PhpStatus";
|
||||
|
||||
const fromComposer: PhpLanguageLevels = {
|
||||
effective: { version: "7.4.0", source: "require" },
|
||||
auto: { version: "7.4.0", source: "require" },
|
||||
member: null,
|
||||
};
|
||||
|
||||
const focus = (language: string, path: string) =>
|
||||
act(() => {
|
||||
setContext("editorLangId", language);
|
||||
setContext("editorPath", path);
|
||||
});
|
||||
|
||||
let dispose: () => void;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.level.mockResolvedValue(fromComposer);
|
||||
mocks.set.mockImplementation((_scope: string, _key: string, value: string) =>
|
||||
Promise.resolve({ languages: { php: { languageLevel: JSON.parse(value) } } }),
|
||||
);
|
||||
useProject.setState({ project: { root: "/project" } as ProjectInfo, settings: null });
|
||||
setContext("projectOpen", true);
|
||||
setContext("editorLangId", null);
|
||||
setContext("editorPath", null);
|
||||
dispose = activatePhpStatus();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
cleanup();
|
||||
dispose();
|
||||
});
|
||||
|
||||
const mount = () => {
|
||||
const Component = mocks.component!;
|
||||
return render(<Component />);
|
||||
};
|
||||
|
||||
const pick = async (label: string) => {
|
||||
await act(() => executeAction("php.languageLevelPopup"));
|
||||
fireEvent.click(await screen.findByRole("menuitemcheckbox", { name: label }));
|
||||
};
|
||||
|
||||
describe("PHP language level status", () => {
|
||||
it("stays hidden for other editors without asking the backend", async () => {
|
||||
mount();
|
||||
await focus("typescript", "/project/src/app.ts");
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
expect(mocks.level).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the effective level of the focused PHP file and where it comes from", async () => {
|
||||
mount();
|
||||
await focus("php", "/project/src/Invoice.php");
|
||||
const button = await screen.findByRole("button", { name: "PHP Language Level: PHP 7.4" });
|
||||
expect(button.textContent).toBe("PHP 7.4");
|
||||
expect(button.dataset.tooltip).toBe("PHP 7.4.0, the lowest version require.php in composer.json allows");
|
||||
expect(mocks.level).toHaveBeenCalledWith("/project/src/Invoice.php");
|
||||
await focus("markdown", "/project/README.md");
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
});
|
||||
|
||||
it("names config.platform.php and the language server default", async () => {
|
||||
mocks.level.mockResolvedValueOnce({
|
||||
effective: { version: "7.2.34", source: "platform" },
|
||||
auto: null,
|
||||
member: null,
|
||||
});
|
||||
mount();
|
||||
await focus("php", "/project/a.php");
|
||||
const button = await screen.findByRole("button", { name: "PHP Language Level: PHP 7.2" });
|
||||
expect(button.dataset.tooltip).toBe("PHP 7.2.34, from config.platform.php in composer.json");
|
||||
mocks.level.mockResolvedValueOnce({ effective: null, auto: null, member: null });
|
||||
await focus("php", "/project/b.php");
|
||||
const fallback = await screen.findByRole("button", { name: "PHP Language Level: PHP" });
|
||||
expect(fallback.dataset.tooltip).toBe(
|
||||
"No PHP version in the settings or composer.json: Intelephense uses its default",
|
||||
);
|
||||
});
|
||||
|
||||
it("writes the picked level to the project settings", async () => {
|
||||
mount();
|
||||
await focus("php", "/project/src/Invoice.php");
|
||||
await screen.findByRole("button", { name: "PHP Language Level: PHP 7.4" });
|
||||
mocks.level.mockResolvedValue({ ...fromComposer, effective: { version: "8.1.0", source: "settings" } });
|
||||
await pick("8.1");
|
||||
await waitFor(() =>
|
||||
expect(mocks.set).toHaveBeenCalledWith("project", "languages.php.languageLevel", JSON.stringify("8.1")),
|
||||
);
|
||||
const button = await screen.findByRole("button", { name: "PHP Language Level: PHP 8.1" });
|
||||
expect(button.dataset.tooltip).toBe("PHP 8.1.0, set in the settings");
|
||||
expect(useProject.getState().settings?.languages?.php?.languageLevel).toBe("8.1");
|
||||
});
|
||||
|
||||
it("shows what Auto resolves to and clears the level with it", async () => {
|
||||
mocks.level.mockResolvedValue({ ...fromComposer, effective: { version: "8.1.0", source: "settings" } });
|
||||
useProject.setState({ settings: { languages: { php: { languageLevel: "8.1" } } } as Settings });
|
||||
mount();
|
||||
await focus("php", "/project/src/Invoice.php");
|
||||
await screen.findByRole("button", { name: "PHP Language Level: PHP 8.1" });
|
||||
await act(() => executeAction("php.languageLevelPopup"));
|
||||
expect(screen.getByRole("menuitemcheckbox", { name: "8.1" }).getAttribute("aria-checked")).toBe("true");
|
||||
const auto = screen.getByRole("menuitemcheckbox", { name: /^Auto/ });
|
||||
expect(auto.getAttribute("aria-checked")).toBe("false");
|
||||
expect(auto.textContent).toContain("7.4");
|
||||
mocks.level.mockResolvedValue(fromComposer);
|
||||
fireEvent.click(auto);
|
||||
await waitFor(() =>
|
||||
expect(mocks.set).toHaveBeenCalledWith("project", "languages.php.languageLevel", JSON.stringify("")),
|
||||
);
|
||||
expect(await screen.findByRole("button", { name: "PHP Language Level: PHP 7.4" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("re-reads the level once after composer.json of the project or a linked project changes", async () => {
|
||||
useProject.setState({ project: { root: "/project", workspace: { projects: ["/linked/api"] } } as ProjectInfo });
|
||||
mount();
|
||||
await focus("php", "/project/src/Invoice.php");
|
||||
await screen.findByRole("button", { name: "PHP Language Level: PHP 7.4" });
|
||||
vi.useFakeTimers();
|
||||
const change = (...modified: string[]) => act(() => mocks.fsChanged!({ created: [], modified, removed: [] }));
|
||||
await change("/project/vendor/acme/lib/composer.json", "/project/src/composer.json");
|
||||
await act(() => vi.advanceTimersByTimeAsync(1000));
|
||||
expect(mocks.level).toHaveBeenCalledTimes(1);
|
||||
// Each change in a burst restarts the wait.
|
||||
await change("/project/composer.json");
|
||||
await act(() => vi.advanceTimersByTimeAsync(200));
|
||||
await change("/project/composer.json");
|
||||
await act(() => vi.advanceTimersByTimeAsync(299));
|
||||
expect(mocks.level).toHaveBeenCalledTimes(1);
|
||||
await act(() => vi.advanceTimersByTimeAsync(1));
|
||||
expect(mocks.level).toHaveBeenCalledTimes(2);
|
||||
await change("/linked/api/composer.json");
|
||||
await act(() => vi.advanceTimersByTimeAsync(300));
|
||||
expect(mocks.level).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("re-reads the level when the PHP language level setting changes, not other settings", async () => {
|
||||
mount();
|
||||
await focus("php", "/project/src/Invoice.php");
|
||||
await screen.findByRole("button", { name: "PHP Language Level: PHP 7.4" });
|
||||
act(() => useProject.setState({ settings: { editor: { fontSize: 15 } } as Settings }));
|
||||
expect(mocks.level).toHaveBeenCalledTimes(1);
|
||||
act(() => useProject.setState({ settings: { languages: { php: { languageLevel: "8.1" } } } as Settings }));
|
||||
await waitFor(() => expect(mocks.level).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("shows a workspace member's level but leaves its choice to the member's settings", async () => {
|
||||
mocks.level.mockResolvedValue({ ...fromComposer, member: "/project/api" });
|
||||
mount();
|
||||
await focus("php", "/project/api/src/Invoice.php");
|
||||
await screen.findByRole("button", { name: "PHP Language Level: PHP 7.4" });
|
||||
await act(() => executeAction("php.languageLevelPopup"));
|
||||
expect(screen.getByText("Set in the settings of the linked project api")).toBeTruthy();
|
||||
const items = screen.getAllByRole("menuitemcheckbox");
|
||||
expect(items.map((item) => item.getAttribute("aria-disabled"))).toEqual(items.map(() => "true"));
|
||||
expect(screen.getByRole("menuitemcheckbox", { name: /^Auto/ }).getAttribute("aria-checked")).toBe("true");
|
||||
fireEvent.click(screen.getByRole("menuitemcheckbox", { name: "8.1" }));
|
||||
expect(mocks.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops the previous file's level and menu when another PHP file is focused", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
mocks.level.mockResolvedValueOnce({ ...fromComposer, member: "/project/api" });
|
||||
mount();
|
||||
await focus("php", "/project/api/src/Order.php");
|
||||
await screen.findByRole("button", { name: "PHP Language Level: PHP 7.4" });
|
||||
await act(() => executeAction("php.languageLevelPopup"));
|
||||
expect(screen.getByRole("menu")).toBeTruthy();
|
||||
mocks.level.mockRejectedValueOnce(new Error("project closed"));
|
||||
await focus("php", "/project/src/Invoice.php");
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
await waitFor(() => expect(warn).toHaveBeenCalled());
|
||||
expect(mocks.level).toHaveBeenLastCalledWith("/project/src/Invoice.php");
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
// The menu does not come back with the next level.
|
||||
await focus("php", "/project/api/src/Order.php");
|
||||
await screen.findByRole("button", { name: "PHP Language Level: PHP 7.4" });
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it("ignores levels it does not list and reports write failures", async () => {
|
||||
await executeAction("php.selectLanguageLevel", "9.9");
|
||||
expect(mocks.set).not.toHaveBeenCalled();
|
||||
mocks.set.mockRejectedValueOnce(new Error("write failed"));
|
||||
await executeAction("php.selectLanguageLevel", "8.1");
|
||||
expect(mocks.notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error" }));
|
||||
expect(useProject.getState().settings).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the PHP settings page while the widget is hidden", async () => {
|
||||
const run = vi.fn();
|
||||
const off = registerAction({ id: "file.settings", title: "Settings…", run });
|
||||
mount();
|
||||
await focus("typescript", "/project/src/app.ts");
|
||||
await executeAction("php.languageLevelPopup");
|
||||
expect(run.mock.calls.map(([ctx]) => ctx.arg)).toEqual(["languages.php"]);
|
||||
off();
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,7 @@ import { installActionProxies, installSettingsHost } from "./window/bridge";
|
||||
import { AssignShortcutOverlay, showAssignShortcut } from "./keymap/AssignShortcut";
|
||||
import { settingsSearchEntries } from "./searchIndex";
|
||||
import { activatePythonStatus } from "./languages/PythonStatus";
|
||||
import { activatePhpStatus } from "./languages/PhpStatus";
|
||||
import { nativeToAsciiAction } from "./nativeToAscii";
|
||||
import { isImeKey } from "@/platform/ime";
|
||||
import { trackIconThemePages } from "./iconThemePages";
|
||||
@@ -109,6 +110,7 @@ export function activate(): () => void {
|
||||
trackRuntimeSettings(),
|
||||
trackIconThemePages(),
|
||||
activatePythonStatus(),
|
||||
activatePhpStatus(),
|
||||
unloadBuiltinPages,
|
||||
registerOverlay({ id: "settings.dialog", component: LazySettingsDialog }),
|
||||
registerActions(zoomActions()),
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { useId, useMemo } from "react";
|
||||
import { IconButton, Input, type SelectOption } from "@/ui";
|
||||
import { PHP_LANGUAGE_LEVELS } from "@/platform/languageSettings";
|
||||
import { Row, Section, Select, Toggle } from "../components/Form";
|
||||
import { GlobListEditor, Row, Section, Select, Toggle } from "../components/Form";
|
||||
import { choosePath } from "./api";
|
||||
import { useSetting } from "../components/context";
|
||||
import { SdkField, tildify, useDetected } from "./components";
|
||||
@@ -120,6 +120,8 @@ const PHP_LEVEL_OPTIONS: SelectOption[] = PHP_LANGUAGE_LEVELS.map((level) =>
|
||||
export function PhpPage() {
|
||||
const [interpreter] = useSetting<string>("languages.php.interpreter");
|
||||
const [level, setLevel] = useSetting<string>("languages.php.languageLevel");
|
||||
const [includePaths, setIncludePaths] = useSetting<string[]>("languages.php.includePaths");
|
||||
const [stubs, setStubs] = useSetting<string[]>("languages.php.stubs");
|
||||
const { list } = useDetected("php");
|
||||
const selected = list.find((s) => s.path === interpreter) ?? (interpreter ? undefined : list[0]);
|
||||
return (
|
||||
@@ -131,6 +133,34 @@ export function PhpPage() {
|
||||
>
|
||||
<Select value={level} options={PHP_LEVEL_OPTIONS} onChange={setLevel} aria-label="PHP language level" />
|
||||
</Row>
|
||||
<Row
|
||||
label="Include paths"
|
||||
stacked
|
||||
hint="Folders outside the project whose classes and functions Intelephense indexes too, such as the platform a module extends. Absolute, or relative to the project."
|
||||
>
|
||||
<GlobListEditor
|
||||
value={includePaths ?? []}
|
||||
onChange={setIncludePaths}
|
||||
placeholder="/path/to/platform"
|
||||
emptyText="No include paths"
|
||||
itemLabel="Include path"
|
||||
aria-label="Include paths"
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
label="Additional stubs"
|
||||
stacked
|
||||
hint="Intelephense stub sets loaded on top of its defaults, such as wordpress. Each ext-* in require or require-dev of composer.json adds its own."
|
||||
>
|
||||
<GlobListEditor
|
||||
value={stubs ?? []}
|
||||
onChange={setStubs}
|
||||
placeholder="Stub name, e.g. wordpress"
|
||||
emptyText="No additional stubs"
|
||||
itemLabel="Stub"
|
||||
aria-label="Additional stubs"
|
||||
/>
|
||||
</Row>
|
||||
</Section>
|
||||
<Section title="CLI Interpreter">
|
||||
<SdkRow
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { create } from "zustand";
|
||||
import { executeAction, registerActions } from "@/platform/actions";
|
||||
import { getContext, useContextKeys } from "@/platform/context";
|
||||
import { call, commands, onIdeEvent, type PhpLanguageLevel, type PhpLanguageLevels } from "@/platform/ipc";
|
||||
import { PHP_LANGUAGE_LEVELS } from "@/platform/languageSettings";
|
||||
import { basename, useProject } from "@/platform/project";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { notifications, registerStatusItem } from "@/workbench/contributions";
|
||||
import { BarButton, Menu, type MenuItem } from "@/ui";
|
||||
import { phpLanguageLevel } from "./api";
|
||||
|
||||
/** What the widget last resolved for a file, so the popup action can read it without a render. */
|
||||
interface Status extends PhpLanguageLevels {
|
||||
root: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const usePopup = create<{ open: boolean; status: Status | null }>(() => ({ open: false, status: null }));
|
||||
const close = () => usePopup.setState({ open: false });
|
||||
const reset = () => usePopup.setState({ open: false, status: null });
|
||||
|
||||
/** Whether the widget is on screen, read straight from the stores (the action runs outside a render). */
|
||||
function popupShown(): boolean {
|
||||
const root = useProject.getState().project?.root;
|
||||
const status = usePopup.getState().status;
|
||||
const context = getContext();
|
||||
return !!root && status?.root === root && status.path === context.editorPath && context.editorLangId === "php";
|
||||
}
|
||||
|
||||
/** `8.1.0` as `8.1`, the way the levels are listed. */
|
||||
const shortVersion = (level: PhpLanguageLevel) => level.version.split(".").slice(0, 2).join(".");
|
||||
|
||||
function origin(level: PhpLanguageLevel | null): string {
|
||||
if (!level) return tx("No PHP version in the settings or composer.json: Intelephense uses its default");
|
||||
const version = level.version;
|
||||
switch (level.source) {
|
||||
case "settings":
|
||||
return tx`PHP ${version}, set in the settings`;
|
||||
case "platform":
|
||||
return tx`PHP ${version}, from config.platform.php in composer.json`;
|
||||
case "require":
|
||||
return tx`PHP ${version}, the lowest version require.php in composer.json allows`;
|
||||
}
|
||||
}
|
||||
|
||||
function PhpStatus() {
|
||||
const root = useProject((s) => s.project?.root);
|
||||
const linked = useProject((s) => s.project?.workspace?.projects);
|
||||
const configured = useProject((s) => s.settings?.languages?.php?.languageLevel ?? "");
|
||||
const path = useContextKeys((s) =>
|
||||
s.editorLangId === "php" && typeof s.editorPath === "string" ? s.editorPath : null,
|
||||
);
|
||||
const status = usePopup((s) => s.status);
|
||||
const open = usePopup((s) => s.open);
|
||||
const anchor = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => reset, [root]);
|
||||
// The menu belongs to the focused file.
|
||||
useEffect(() => close, [path]);
|
||||
useEffect(() => {
|
||||
if (!root || !path) return;
|
||||
let live = true;
|
||||
let sequence = 0;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
// Auto reads the composer.json of the project or of the linked project the file is in.
|
||||
const manifests = new Set([root, ...(linked ?? [])].map((dir) => `${dir}/composer.json`));
|
||||
const refresh = async () => {
|
||||
const request = ++sequence;
|
||||
try {
|
||||
const level = await phpLanguageLevel(path);
|
||||
if (live && sequence === request) usePopup.setState({ status: level && { root, path, ...level } });
|
||||
} catch (e) {
|
||||
console.warn("Cannot read the PHP language level", e);
|
||||
}
|
||||
};
|
||||
void refresh();
|
||||
const off = onIdeEvent("fsChanged", (e) => {
|
||||
if (![...e.created, ...e.modified, ...e.removed].some((file) => manifests.has(file))) return;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => void refresh(), 300);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
clearTimeout(timer);
|
||||
off();
|
||||
};
|
||||
}, [root, linked, path, configured]);
|
||||
// The widget only renders once its project's level is in the store, so the popup action
|
||||
// sees the same state as the button without waiting for an effect to publish a flag.
|
||||
if (!path || !popupShown() || !status) return null;
|
||||
const { effective, auto, member } = status;
|
||||
const label = effective ? `PHP ${shortVersion(effective)}` : "PHP";
|
||||
const chosen = effective?.source === "settings" ? shortVersion(effective) : "";
|
||||
// The choices write the workspace's settings, which a member does not read.
|
||||
const name = member && basename(member);
|
||||
const items: MenuItem[] = [
|
||||
{ kind: "header", label: "PHP Language Level" },
|
||||
...(name
|
||||
? [{ kind: "header", label: tx`Set in the settings of the linked project ${name}` } satisfies MenuItem]
|
||||
: []),
|
||||
...PHP_LANGUAGE_LEVELS.map((level): MenuItem =>
|
||||
level
|
||||
? {
|
||||
label: level,
|
||||
verbatim: true,
|
||||
checked: level === chosen,
|
||||
disabled: !!member,
|
||||
run: () => void executeAction("php.selectLanguageLevel", level),
|
||||
}
|
||||
: {
|
||||
label: "Auto",
|
||||
shortcut: auto ? shortVersion(auto) : undefined,
|
||||
checked: !chosen,
|
||||
disabled: !!member,
|
||||
run: () => void executeAction("php.selectLanguageLevel", ""),
|
||||
},
|
||||
),
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<BarButton
|
||||
ref={anchor}
|
||||
tooltip={origin(effective)}
|
||||
aria-label={tx`PHP Language Level: ${label}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => void executeAction("php.languageLevelPopup")}
|
||||
>
|
||||
{label}
|
||||
</BarButton>
|
||||
<Menu
|
||||
open={open}
|
||||
onClose={close}
|
||||
anchor={anchor}
|
||||
placement="top-end"
|
||||
items={items}
|
||||
aria-label="PHP Language Level"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function activatePhpStatus(): () => void {
|
||||
const dispose = [
|
||||
registerStatusItem({
|
||||
id: "php.languageLevel",
|
||||
title: "PHP Language Level",
|
||||
align: "right",
|
||||
priority: 89,
|
||||
component: PhpStatus,
|
||||
}),
|
||||
registerActions([
|
||||
{
|
||||
id: "php.languageLevelPopup",
|
||||
title: "PHP Language Level",
|
||||
category: "Settings",
|
||||
keywords: ["php version"],
|
||||
when: "projectOpen",
|
||||
run: () =>
|
||||
popupShown()
|
||||
? usePopup.setState((s) => ({ open: !s.open }))
|
||||
: void executeAction("file.settings", "languages.php"),
|
||||
},
|
||||
{
|
||||
id: "php.selectLanguageLevel",
|
||||
title: "Select PHP Language Level",
|
||||
category: "Settings",
|
||||
hidden: true,
|
||||
run: async ({ arg }) => {
|
||||
const project = useProject.getState().project;
|
||||
if (!project || typeof arg !== "string" || !PHP_LANGUAGE_LEVELS.some((level) => level === arg)) return;
|
||||
try {
|
||||
const settings = await call(
|
||||
commands.settingsSet("project", "languages.php.languageLevel", JSON.stringify(arg)),
|
||||
);
|
||||
if (useProject.getState().project === project) useProject.setState({ settings });
|
||||
} catch (e) {
|
||||
notifications().notify({ level: "error", title: "Cannot select PHP language level", body: String(e) });
|
||||
}
|
||||
},
|
||||
},
|
||||
]),
|
||||
];
|
||||
return () => {
|
||||
reset();
|
||||
dispose.reverse().forEach((d) => d());
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,16 @@
|
||||
*/
|
||||
import { Channel } from "@tauri-apps/api/core";
|
||||
import { create } from "zustand";
|
||||
import { call, commands, isTauri, nextRequestId, type PythonPackage, type Sdk, type SdkKind } from "@/platform/ipc";
|
||||
import {
|
||||
call,
|
||||
commands,
|
||||
isTauri,
|
||||
nextRequestId,
|
||||
type PhpLanguageLevels,
|
||||
type PythonPackage,
|
||||
type Sdk,
|
||||
type SdkKind,
|
||||
} from "@/platform/ipc";
|
||||
|
||||
export type { PythonPackage, Sdk, SdkKind };
|
||||
|
||||
@@ -97,6 +106,11 @@ export function effectivePython(): Promise<string | null> {
|
||||
return isTauri() ? call(commands.sdkPythonEffective()) : Promise.resolve(null);
|
||||
}
|
||||
|
||||
/** The PHP language level of the project `path` belongs to; null outside Tauri. */
|
||||
export function phpLanguageLevel(path: string): Promise<PhpLanguageLevels | null> {
|
||||
return isTauri() ? call(commands.sdkPhpLanguageLevel(path)) : Promise.resolve(null);
|
||||
}
|
||||
|
||||
/** File / folder chooser (Tauri dialog); null when cancelled or outside Tauri. */
|
||||
export async function choosePath(opts: {
|
||||
directory: boolean;
|
||||
|
||||
@@ -154,6 +154,18 @@ describe("expansion", () => {
|
||||
for (const a of ["psvm", "sout", "fori", "iter", "main"]) expect(has("java", a)).toBe(true);
|
||||
for (const a of ["main", "fun", "val", "sout"]) expect(has("kotlin", a)).toBe(true);
|
||||
for (const a of ["todo", "fixme"]) expect(has("go", a)).toBe(true);
|
||||
for (const a of ["fore", "forek", "pubf", "prisf", "thr", "eco", "rqro"]) expect(has("php", a)).toBe(true);
|
||||
expect(has("java", "fore")).toBe(false);
|
||||
});
|
||||
|
||||
it("writes PHP variables with their $", () => {
|
||||
const php = (abbrev: string) => BUILTIN_TEMPLATES.find((t) => t.id === `php.${abbrev}`)!;
|
||||
const fore = apply("", expandTemplate(php("fore")).snippet, 0);
|
||||
expect(fore.text).toBe("foreach ( as $value) {\n \n}");
|
||||
// The caret starts on the iterable.
|
||||
expect(fore.sel[0]).toEqual([9, 9]);
|
||||
expect(apply("", expandTemplate(php("forek")).snippet, 0).text).toBe("foreach ( as $key => $value) {\n \n}");
|
||||
expect(apply("", expandTemplate(php("prosf")).snippet, 0).text).toBe("protected static function ()\n{\n \n}");
|
||||
});
|
||||
|
||||
it("merges user edits, deletions and user templates", () => {
|
||||
|
||||
@@ -264,6 +264,45 @@ const kotlin = group(
|
||||
],
|
||||
);
|
||||
|
||||
const method = (abbrev: string, modifiers: string): Def => ({
|
||||
abbrev,
|
||||
description: `${modifiers} function`,
|
||||
body: `${modifiers} function $NAME$($PARAMETERS$)\n{\n\t$END$\n}`,
|
||||
});
|
||||
|
||||
/** PHP's `$` is written `$$` in a template body. */
|
||||
const php = group(
|
||||
"PHP",
|
||||
"php",
|
||||
["php"],
|
||||
[
|
||||
{
|
||||
abbrev: "fore",
|
||||
description: "foreach (iterable as value)",
|
||||
body: "foreach ($ITERABLE$ as $$$VALUE$) {\n\t$END$\n}",
|
||||
variables: [v("ITERABLE"), v("VALUE", undefined, '"value"')],
|
||||
},
|
||||
{
|
||||
abbrev: "forek",
|
||||
description: "foreach (iterable as key => value)",
|
||||
body: "foreach ($ITERABLE$ as $$$KEY$ => $$$VALUE$) {\n\t$END$\n}",
|
||||
variables: [v("ITERABLE"), v("KEY", undefined, '"key"'), v("VALUE", undefined, '"value"')],
|
||||
},
|
||||
method("pubf", "public"),
|
||||
method("prof", "protected"),
|
||||
method("prif", "private"),
|
||||
method("pubsf", "public static"),
|
||||
method("prosf", "protected static"),
|
||||
method("prisf", "private static"),
|
||||
{ abbrev: "thr", description: "throw new", body: "throw new $END$;" },
|
||||
{ abbrev: "eco", description: "echo", body: 'echo "$END$";' },
|
||||
{ abbrev: "inc", description: "include", body: "include '$END$';" },
|
||||
{ abbrev: "inco", description: "include_once", body: "include_once '$END$';" },
|
||||
{ abbrev: "rqr", description: "require", body: "require '$END$';" },
|
||||
{ abbrev: "rqro", description: "require_once", body: "require_once '$END$';" },
|
||||
],
|
||||
);
|
||||
|
||||
const general = group(
|
||||
"General",
|
||||
"any",
|
||||
@@ -458,6 +497,7 @@ export const BUILTIN_TEMPLATES: readonly LiveTemplate[] = [
|
||||
...go,
|
||||
...java,
|
||||
...kotlin,
|
||||
...php,
|
||||
...sqlQuery,
|
||||
...sqlStatement,
|
||||
...sqlCode,
|
||||
|
||||
@@ -31,6 +31,12 @@ function gateWithRebase() {
|
||||
return { gate, run, pending, rebases: () => rebased };
|
||||
}
|
||||
|
||||
/** Collects the emissions that reach the PTY, like `Session.fromTerminal`. */
|
||||
function emitter(gate: ImeCommitGate) {
|
||||
const sent: string[] = [];
|
||||
return { sent, emit: (data: string) => gate.accept() && sent.push(data) };
|
||||
}
|
||||
|
||||
describe("ImeCommitGate", () => {
|
||||
it("takes the first emission of a commit and drops the repeated one", () => {
|
||||
const { gate } = gateWithRebase();
|
||||
@@ -42,10 +48,7 @@ describe("ImeCommitGate", () => {
|
||||
it("keeps the real text of the pi case and drops the stale suffix", () => {
|
||||
// xterm sends the committed diff first, then a stale `value.substring(start)`.
|
||||
const { gate, run } = gateWithRebase();
|
||||
const sent: string[] = [];
|
||||
const emit = (data: string) => {
|
||||
if (gate.accept()) sent.push(data);
|
||||
};
|
||||
const { sent, emit } = emitter(gate);
|
||||
gate.begin();
|
||||
emit("在吗");
|
||||
gate.finish();
|
||||
@@ -54,37 +57,101 @@ describe("ImeCommitGate", () => {
|
||||
expect(sent).toEqual(["在吗"]);
|
||||
});
|
||||
|
||||
it("accepts every emission once the commit is over", () => {
|
||||
const { gate, run } = gateWithRebase();
|
||||
it("lets a key the IME did not take through right after a commit", () => {
|
||||
// Punctuation committed directly, then Enter within the commit's timeout: Enter must run.
|
||||
const { gate, rebases } = gateWithRebase();
|
||||
const { sent, emit } = emitter(gate);
|
||||
gate.begin();
|
||||
emit(",");
|
||||
gate.keystroke();
|
||||
emit("\r");
|
||||
expect(sent).toEqual([",", "\r"]);
|
||||
expect(rebases()).toBe(1);
|
||||
// Outside a commit, or during a composition, a keystroke changes nothing.
|
||||
gate.keystroke();
|
||||
gate.compositionStart();
|
||||
gate.keystroke();
|
||||
expect(rebases()).toBe(1);
|
||||
});
|
||||
|
||||
it("starts a new commit on the next IME keystroke even inside the timeout", () => {
|
||||
// Two commits can arrive back to back; the second must not be swallowed by the first
|
||||
// commit's still-running timeout.
|
||||
const { gate } = gateWithRebase();
|
||||
gate.begin();
|
||||
expect(gate.accept()).toBe(true);
|
||||
gate.begin();
|
||||
expect(gate.accept()).toBe(true);
|
||||
expect(gate.accept()).toBe(false);
|
||||
});
|
||||
|
||||
it("drops the stale suffix of a WebKitGTK punctuation commit without compositionstart", () => {
|
||||
// fcitx5 commits `,` as insertFromComposition + compositionend without compositionstart:
|
||||
// xterm sends the keydown diff `,`, then compositionend emits `好,` from the previous
|
||||
// composition's position. That suffix is what showed up as a duplicate in the terminal.
|
||||
const { gate, run, rebases } = gateWithRebase();
|
||||
const { sent, emit } = emitter(gate);
|
||||
gate.begin();
|
||||
emit(",");
|
||||
gate.finish();
|
||||
emit("好,");
|
||||
run();
|
||||
expect(sent).toEqual([","]);
|
||||
expect(rebases()).toBe(1);
|
||||
});
|
||||
|
||||
it("stands down for a real composition, then gates the next commit again", () => {
|
||||
// compositionstart must not disable the gate for the whole session: the very next
|
||||
// punctuation commit still needs deduplication.
|
||||
const { gate, run, rebases } = gateWithRebase();
|
||||
gate.begin();
|
||||
gate.compositionStart();
|
||||
expect(gate.accept()).toBe(true);
|
||||
expect(gate.accept()).toBe(true);
|
||||
gate.finish();
|
||||
run();
|
||||
expect(rebases()).toBe(1);
|
||||
|
||||
gate.begin();
|
||||
expect(gate.accept()).toBe(true);
|
||||
expect(gate.accept()).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts every emission while composing", () => {
|
||||
const { gate } = gateWithRebase();
|
||||
gate.compositionStart();
|
||||
expect(gate.accept()).toBe(true);
|
||||
expect(gate.accept()).toBe(true);
|
||||
expect(gate.accept()).toBe(true);
|
||||
});
|
||||
|
||||
it("rebases the textarea when the commit closes, not before", () => {
|
||||
it("rebases after a composition that xterm sent by itself", () => {
|
||||
const { gate, run, rebases } = gateWithRebase();
|
||||
gate.begin();
|
||||
gate.compositionStart();
|
||||
gate.finish();
|
||||
expect(rebases()).toBe(0);
|
||||
run();
|
||||
expect(rebases()).toBe(1);
|
||||
});
|
||||
|
||||
it("stands down once a real compositionstart arrives", () => {
|
||||
const { gate, rebases } = gateWithRebase();
|
||||
it("rebases after a commit whose compositionend arrives, not before", () => {
|
||||
const { gate, run, rebases } = gateWithRebase();
|
||||
gate.begin();
|
||||
gate.compositionStart();
|
||||
expect(gate.accept()).toBe(true);
|
||||
expect(gate.accept()).toBe(true);
|
||||
gate.finish();
|
||||
expect(rebases()).toBe(0);
|
||||
// And a later commit is not gated either.
|
||||
gate.begin();
|
||||
expect(gate.accept()).toBe(true);
|
||||
run();
|
||||
expect(rebases()).toBe(1);
|
||||
});
|
||||
|
||||
it("opens a commit from a composition input when no keydown preceded it", () => {
|
||||
const { gate } = gateWithRebase();
|
||||
gate.input();
|
||||
expect(gate.accept()).toBe(true);
|
||||
expect(gate.accept()).toBe(false);
|
||||
// A second input event belongs to the same commit and must not reopen it.
|
||||
gate.input();
|
||||
expect(gate.accept()).toBe(false);
|
||||
});
|
||||
|
||||
it("closes a commit whose compositionend never arrives", () => {
|
||||
@@ -96,4 +163,13 @@ describe("ImeCommitGate", () => {
|
||||
expect(rebases()).toBe(1);
|
||||
expect(gate.accept()).toBe(true);
|
||||
});
|
||||
|
||||
it("does not rebase into a composition that started before the close ran", () => {
|
||||
const { gate, run, rebases } = gateWithRebase();
|
||||
gate.begin();
|
||||
gate.finish();
|
||||
gate.compositionStart();
|
||||
run();
|
||||
expect(rebases()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,31 @@
|
||||
/**
|
||||
* Collapses the repeated data emissions of one IME commit.
|
||||
*
|
||||
* WebKitGTK + fcitx5 commit text without firing `compositionstart`, so xterm's `CompositionHelper`
|
||||
* never advances `_compositionPosition.start` and keeps appending the committed text to its hidden
|
||||
* textarea. A commit then reaches `onData` twice — once with the committed text, once with a stale
|
||||
* `value.substring(start)` — and the same text shows up in the terminal twice.
|
||||
* WebKitGTK commits IME text with an inconsistent mix of events, and xterm's
|
||||
* `CompositionHelper` only copes with part of it:
|
||||
*
|
||||
* One commit must send one chunk: take the first emission and drop the rest, then rebase the
|
||||
* textarea so the next commit starts from an empty value. IMEs that do report `compositionstart`
|
||||
* (macOS, IBus) keep xterm's own bookkeeping, so the gate stands down for them.
|
||||
* - A composed word (`你好`) reports `compositionstart`, so xterm records where the
|
||||
* composition starts in its hidden textarea and sends the committed text once on
|
||||
* `compositionend`.
|
||||
* - Text that the IME commits directly (punctuation such as `;` or `,`) reports
|
||||
* `insertFromComposition` + `compositionend` **without** `compositionstart`. xterm then
|
||||
* finalizes the *previous* composition's position: it sends the keydown's textarea diff
|
||||
* first and, once `compositionend` runs, a stale `value.substring(start)` on top. That
|
||||
* second emission is the duplicated suffix users see (typing `;你好,` sends `,` and then
|
||||
* `好,`). Newer WebKitGTK delivers the same commits as `insertText` without any
|
||||
* composition events, which xterm sends through its keydown path; the stale emission there
|
||||
* carries the previous commit's text instead.
|
||||
*
|
||||
* One commit must send one chunk, so the gate lets the first emission through and drops the
|
||||
* rest until the commit closes. A commit is delimited by the IME's own keystroke (code 229)
|
||||
* outside a composition, or by a composition input when the platform sends no keydown.
|
||||
* Commits that really compose are left to xterm: `compositionstart` stands the gate down for
|
||||
* that commit only — it must not disable the gate for the session, because the very next
|
||||
* punctuation commit still needs deduplication.
|
||||
*
|
||||
* xterm never clears its hidden textarea by itself, so every commit ends with a rebase: the
|
||||
* textarea is emptied after xterm's own senders have run, keeping the next commit from
|
||||
* carrying old text.
|
||||
*/
|
||||
|
||||
/** How long a commit may stay open when its `compositionend` never arrives. */
|
||||
@@ -27,8 +44,8 @@ const WINDOW_SCHEDULER: ImeScheduler = {
|
||||
export class ImeCommitGate {
|
||||
private open = false;
|
||||
private sent = false;
|
||||
private composing = false;
|
||||
private timer: number | null = null;
|
||||
private native = false;
|
||||
private readonly rebase: () => void;
|
||||
private readonly scheduler: ImeScheduler;
|
||||
|
||||
@@ -37,21 +54,61 @@ export class ImeCommitGate {
|
||||
this.scheduler = scheduler;
|
||||
}
|
||||
|
||||
/** A commit starts: the IME's own keystroke (code 229) or its composition input event. */
|
||||
/**
|
||||
* A commit starts: the IME's own keystroke (code 229) outside a composition.
|
||||
*
|
||||
* Always starts a fresh commit, even when the previous one is still inside its timeout:
|
||||
* two commits can arrive back to back, and the second must not be swallowed.
|
||||
*/
|
||||
begin(): void {
|
||||
if (this.native || this.open) return;
|
||||
if (this.composing) return;
|
||||
this.cancelTimer();
|
||||
this.open = true;
|
||||
this.sent = false;
|
||||
this.timer = this.scheduler.after(() => this.close(), IME_COMMIT_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
/** The commit ended: let the emissions both of xterm's senders queued go out, then rebase. */
|
||||
/**
|
||||
* A composition input arrived. Opens a commit only when no commit is open: resetting
|
||||
* `sent` here would let the emissions already queued for this commit go out again.
|
||||
*/
|
||||
input(): void {
|
||||
if (this.composing || this.open) return;
|
||||
this.open = true;
|
||||
this.sent = false;
|
||||
this.timer = this.scheduler.after(() => this.close(), IME_COMMIT_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* An IME composition begins. xterm's own composition handling owns this commit, so the
|
||||
* gate stays out of its way — but only until the composition ends.
|
||||
*/
|
||||
compositionStart(): void {
|
||||
this.composing = true;
|
||||
this.cancelTimer();
|
||||
this.open = false;
|
||||
this.sent = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The composition — or, on WebKitGTK, a directly committed character — ended.
|
||||
* Bubble phase: xterm's own `compositionend` listener ran first and queued its send, so a
|
||||
* 0 ms close lands behind that emission and cannot cut the committed text off.
|
||||
*/
|
||||
finish(): void {
|
||||
if (!this.open) return;
|
||||
this.composing = false;
|
||||
this.cancelTimer();
|
||||
this.timer = this.scheduler.after(() => this.close(), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* A keystroke the IME did not take (Enter, an arrow, a letter typed outside it): the commit
|
||||
* is over, and what that key sends must reach the PTY.
|
||||
*/
|
||||
keystroke(): void {
|
||||
if (this.open && !this.composing) this.close();
|
||||
}
|
||||
|
||||
/** Whether this emission should reach the PTY. */
|
||||
accept(): boolean {
|
||||
if (!this.open) return true;
|
||||
@@ -60,19 +117,13 @@ export class ImeCommitGate {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The IME reports composition events, so xterm needs no help — stay out of its way. */
|
||||
compositionStart(): void {
|
||||
this.native = true;
|
||||
this.cancelTimer();
|
||||
this.open = false;
|
||||
this.sent = false;
|
||||
}
|
||||
|
||||
/** Ends the commit and rebases the textarea to an empty value. */
|
||||
close(): void {
|
||||
this.cancelTimer();
|
||||
if (!this.open) return;
|
||||
// A new composition started before this close ran; its own finish will rebase.
|
||||
if (this.composing) return;
|
||||
this.open = false;
|
||||
this.sent = false;
|
||||
this.rebase();
|
||||
}
|
||||
|
||||
|
||||
@@ -413,17 +413,18 @@ export class TerminalSession {
|
||||
|
||||
/**
|
||||
* Routes a WebKitGTK / fcitx5 IME commit through `ImeCommitGate`: the platform reports the
|
||||
* composition's own keystroke (code 229) and its input event but never `compositionstart`,
|
||||
* which is what xterm's own bookkeeping is built on.
|
||||
* commit's own keystroke (code 229) and its input event, but reports `compositionstart` only
|
||||
* for composed words — never for the punctuation an IME commits directly.
|
||||
*/
|
||||
private watchImeCommit() {
|
||||
const textarea = this.term.textarea;
|
||||
if (!textarea) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.keyCode === 229) this.ime.begin();
|
||||
else this.ime.keystroke();
|
||||
};
|
||||
const onInput = (e: InputEvent) => {
|
||||
if (e.inputType === "insertFromComposition" || e.inputType === "insertCompositionText") this.ime.begin();
|
||||
if (e.inputType === "insertFromComposition" || e.inputType === "insertCompositionText") this.ime.input();
|
||||
};
|
||||
const onCompositionStart = () => this.ime.compositionStart();
|
||||
// Bubble phase, i.e. after xterm's own listener: the rebase must land behind the emissions
|
||||
|
||||
@@ -62,9 +62,9 @@
|
||||
.icon {
|
||||
flex: none;
|
||||
}
|
||||
/* The name takes the whole row; when it doesn't fit, the grey detail after it gives way first. */
|
||||
.label {
|
||||
flex: 0 0 auto;
|
||||
max-width: 75%;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -74,6 +74,7 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
.detail {
|
||||
flex: 0 10000 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -190,6 +191,9 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.stashRow > .detail {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
.stashMessage {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -113,6 +113,7 @@ export const ATTRIBUTES: readonly AttributeDef[] = [
|
||||
syn("keyword", "Keyword", [LD], "keyword"),
|
||||
syn("identifier", "Identifiers › Local variable", [LD], "variable"),
|
||||
syn("parameter", "Identifiers › Parameter", [LD], "param"),
|
||||
syn("globalVariable", "Identifiers › Global variable", [LD], "global"),
|
||||
syn("constant", "Identifiers › Constant", [LD], "constant"),
|
||||
syn("field", "Identifiers › Instance field", [LD], "property"),
|
||||
{ id: "staticSymbol", name: "Identifiers › Static symbol", path: [LD], token: "static", italicOnly: true },
|
||||
|
||||
@@ -41,6 +41,17 @@ describe(".icls schemes", () => {
|
||||
expect(customs).toEqual(before);
|
||||
});
|
||||
|
||||
it("imports the global variable colour", () => {
|
||||
const xml =
|
||||
'<scheme name="G" parent_scheme="Darcula"><attributes><option name="DEFAULT_GLOBAL_VARIABLE"><value>' +
|
||||
'<option name="FOREGROUND" value="9876aa"/><option name="FONT_TYPE" value="2"/></value></option></attributes></scheme>';
|
||||
expect(parseIcls(xml).scheme.attributes.globalVariable).toEqual({
|
||||
foreground: "#9876aa",
|
||||
bold: false,
|
||||
italic: true,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Darcula", "ffffff", "dark"],
|
||||
["Default", "000000", "light"],
|
||||
|
||||
@@ -11,6 +11,7 @@ const ATTRIBUTE_NAMES: Record<string, string> = {
|
||||
DEFAULT_IDENTIFIER: "identifier",
|
||||
DEFAULT_LOCAL_VARIABLE: "identifier",
|
||||
DEFAULT_PARAMETER: "parameter",
|
||||
DEFAULT_GLOBAL_VARIABLE: "globalVariable",
|
||||
DEFAULT_CONSTANT: "constant",
|
||||
DEFAULT_INSTANCE_FIELD: "field",
|
||||
DEFAULT_STATIC_FIELD: "constant",
|
||||
|
||||
@@ -76,7 +76,12 @@ export interface LanguagesValid {
|
||||
python: { interpreter: string; activateVirtualenv: boolean };
|
||||
ruby: { interpreter: string };
|
||||
node: { interpreter: string; packageManager: "" | "npm" | "pnpm" | "yarn" | "bun" };
|
||||
php: { interpreter: string; languageLevel: (typeof PHP_LANGUAGE_LEVELS)[number] };
|
||||
php: {
|
||||
interpreter: string;
|
||||
languageLevel: (typeof PHP_LANGUAGE_LEVELS)[number];
|
||||
includePaths: string[];
|
||||
stubs: string[];
|
||||
};
|
||||
rust: {
|
||||
toolchain: string;
|
||||
stdlib: string;
|
||||
@@ -145,7 +150,7 @@ export const DEFAULT_LANGUAGES: LanguagesValid = {
|
||||
python: { interpreter: "", activateVirtualenv: true },
|
||||
ruby: { interpreter: "" },
|
||||
node: { interpreter: "", packageManager: "" },
|
||||
php: { interpreter: "", languageLevel: "" },
|
||||
php: { interpreter: "", languageLevel: "", includePaths: [], stubs: [] },
|
||||
rust: { toolchain: "", stdlib: "", checkOnSave: true, checkCommand: "check", rustfmtArgs: "" },
|
||||
go: { goroot: "", gopath: "", buildTags: DEFAULT_GO_BUILD_TAGS, modules: { env: {}, autoVendor: true } },
|
||||
typescript: { version: "bundled" },
|
||||
@@ -191,6 +196,11 @@ export function normalizeFolder(path: string): string {
|
||||
/** A stored preview font size within IDEA's 8..72, or `null` (follow the editor font). */
|
||||
const previewSize = (v: unknown): number | null =>
|
||||
typeof v === "number" && Number.isFinite(v) ? Math.min(72, Math.max(8, Math.round(v))) : null;
|
||||
/** Non-empty trimmed strings, without duplicates. */
|
||||
const strings = (v: unknown): string[] =>
|
||||
Array.isArray(v)
|
||||
? [...new Set(v.filter((x): x is string => typeof x === "string").map((x) => x.trim()))].filter((x) => x)
|
||||
: [];
|
||||
const folders = (v: unknown): string[] =>
|
||||
Array.isArray(v)
|
||||
? [
|
||||
@@ -249,6 +259,8 @@ export function validateLanguages(raw: unknown): LanguagesValid {
|
||||
php: {
|
||||
interpreter: text(php.interpreter, d.php.interpreter),
|
||||
languageLevel: oneOf(php.languageLevel, PHP_LANGUAGE_LEVELS, d.php.languageLevel),
|
||||
includePaths: strings(php.includePaths),
|
||||
stubs: strings(php.stubs),
|
||||
},
|
||||
rust: {
|
||||
toolchain: text(rust.toolchain, d.rust.toolchain),
|
||||
|
||||
@@ -5761,6 +5761,15 @@
|
||||
"PHP language level": "Niveau de langage PHP",
|
||||
"The PHP version Intelephense checks the code against: newer syntax and functions are reported. Auto uses config.platform.php from composer.json, else the lowest version require.php allows.": "La version de PHP par rapport à laquelle Intelephense vérifie le code : la syntaxe et les fonctions plus récentes sont signalées. Auto utilise config.platform.php de composer.json, sinon la version la plus basse autorisée par require.php.",
|
||||
"from composer.json": "depuis composer.json",
|
||||
"PHP Language Level": "Niveau de langage PHP",
|
||||
"PHP Language Level: {label}": "Niveau de langage PHP : {label}",
|
||||
"Select PHP Language Level": "Sélectionner le niveau de langage PHP",
|
||||
"Cannot select PHP language level": "Impossible de sélectionner le niveau de langage PHP",
|
||||
"No PHP version in the settings or composer.json: Intelephense uses its default": "Aucune version de PHP dans les paramètres ni dans composer.json : Intelephense utilise sa version par défaut",
|
||||
"PHP {version}, set in the settings": "PHP {version}, défini dans les paramètres",
|
||||
"PHP {version}, from config.platform.php in composer.json": "PHP {version}, depuis config.platform.php de composer.json",
|
||||
"PHP {version}, the lowest version require.php in composer.json allows": "PHP {version}, la version la plus basse autorisée par require.php de composer.json",
|
||||
"Set in the settings of the linked project {name}": "Défini dans les paramètres du projet lié {name}",
|
||||
"select files in a git repository to shelve": "sélectionnez les fichiers d'un dépôt git à mettre de côté",
|
||||
"select files from a single git repository to shelve": "sélectionnez les fichiers d'un seul dépôt git à mettre de côté",
|
||||
"External file changes sync may be slow": "La synchronisation des modifications externes de fichiers peut être lente",
|
||||
|
||||
@@ -5615,6 +5615,15 @@
|
||||
"PHP language level": "PHP 언어 레벨",
|
||||
"The PHP version Intelephense checks the code against: newer syntax and functions are reported. Auto uses config.platform.php from composer.json, else the lowest version require.php allows.": "Intelephense가 코드를 검사하는 기준 PHP 버전입니다. 더 새로운 문법과 함수가 보고됩니다. 자동은 composer.json의 config.platform.php를 사용하고, 없으면 require.php가 허용하는 가장 낮은 버전을 사용합니다.",
|
||||
"from composer.json": "composer.json 기준",
|
||||
"PHP Language Level": "PHP 언어 레벨",
|
||||
"PHP Language Level: {label}": "PHP 언어 레벨: {label}",
|
||||
"Select PHP Language Level": "PHP 언어 레벨 선택",
|
||||
"Cannot select PHP language level": "PHP 언어 레벨을 선택할 수 없습니다",
|
||||
"No PHP version in the settings or composer.json: Intelephense uses its default": "설정과 composer.json에 PHP 버전이 없습니다. Intelephense가 기본 버전을 사용합니다",
|
||||
"PHP {version}, set in the settings": "PHP {version}, 설정에서 지정",
|
||||
"PHP {version}, from config.platform.php in composer.json": "PHP {version}, composer.json의 config.platform.php 기준",
|
||||
"PHP {version}, the lowest version require.php in composer.json allows": "PHP {version}, composer.json의 require.php가 허용하는 가장 낮은 버전",
|
||||
"Set in the settings of the linked project {name}": "연결된 프로젝트 {name}의 설정에서 지정",
|
||||
"select files in a git repository to shelve": "셸브할 git 저장소의 파일을 선택하세요",
|
||||
"select files from a single git repository to shelve": "셸브할 단일 git 저장소의 파일을 선택하세요",
|
||||
"External file changes sync may be slow": "외부 파일 변경 사항 동기화가 느릴 수 있습니다",
|
||||
|
||||
@@ -5655,6 +5655,24 @@
|
||||
"PHP language level": "PHP 语言级别",
|
||||
"The PHP version Intelephense checks the code against: newer syntax and functions are reported. Auto uses config.platform.php from composer.json, else the lowest version require.php allows.": "Intelephense 检查代码时所针对的 PHP 版本:更新版本的语法和函数会被报告。“自动”使用 composer.json 中的 config.platform.php,否则使用 require.php 允许的最低版本。",
|
||||
"from composer.json": "来自 composer.json",
|
||||
"PHP Language Level": "PHP 语言级别",
|
||||
"PHP Language Level: {label}": "PHP 语言级别:{label}",
|
||||
"Select PHP Language Level": "选择 PHP 语言级别",
|
||||
"Cannot select PHP language level": "无法选择 PHP 语言级别",
|
||||
"No PHP version in the settings or composer.json: Intelephense uses its default": "设置和 composer.json 中均未指定 PHP 版本:Intelephense 使用其默认版本",
|
||||
"PHP {version}, set in the settings": "PHP {version},在设置中指定",
|
||||
"PHP {version}, from config.platform.php in composer.json": "PHP {version},来自 composer.json 中的 config.platform.php",
|
||||
"PHP {version}, the lowest version require.php in composer.json allows": "PHP {version},composer.json 中 require.php 允许的最低版本",
|
||||
"Set in the settings of the linked project {name}": "在关联项目 {name} 的设置中指定",
|
||||
"Include paths": "包含路径",
|
||||
"Folders outside the project whose classes and functions Intelephense indexes too, such as the platform a module extends. Absolute, or relative to the project.": "Intelephense 额外索引其中类和函数的项目外文件夹,例如模块所扩展的平台。可以是绝对路径,或相对于项目的路径。",
|
||||
"No include paths": "没有包含路径",
|
||||
"Include path": "包含路径",
|
||||
"Additional stubs": "附加存根",
|
||||
"Intelephense stub sets loaded on top of its defaults, such as wordpress. Each ext-* in require or require-dev of composer.json adds its own.": "在 Intelephense 默认存根之外加载的存根集,例如 wordpress。composer.json 的 require 或 require-dev 中的每个 ext-* 会自动加上对应的存根。",
|
||||
"No additional stubs": "没有附加存根",
|
||||
"Stub": "存根",
|
||||
"Stub name, e.g. wordpress": "存根名称,例如 wordpress",
|
||||
"select files in a git repository to shelve": "请选择 Git 仓库中的文件以搁置更改",
|
||||
"select files from a single git repository to shelve": "请一次只选择同一个 Git 仓库中的文件以搁置更改",
|
||||
"External file changes sync may be slow": "外部文件更改同步可能较慢",
|
||||
|
||||
@@ -116,6 +116,7 @@ html {
|
||||
--syn-number: #2aacb8;
|
||||
--syn-punct: #bcbec4;
|
||||
--syn-variable: #bcbec4;
|
||||
--syn-global: var(--syn-variable);
|
||||
--syn-param: #bcbec4;
|
||||
--syn-property: #c77dbb;
|
||||
--syn-constant: #c77dbb;
|
||||
@@ -494,6 +495,7 @@ html {
|
||||
--syn-number: #1750eb;
|
||||
--syn-punct: #080808;
|
||||
--syn-variable: #080808;
|
||||
--syn-global: var(--syn-variable);
|
||||
--syn-param: #080808;
|
||||
--syn-property: #871094;
|
||||
--syn-constant: #871094;
|
||||
@@ -748,6 +750,7 @@ html {
|
||||
--syn-number: #4fd0dc;
|
||||
--syn-punct: #e3e4e8;
|
||||
--syn-variable: #e3e4e8;
|
||||
--syn-global: var(--syn-variable);
|
||||
--syn-param: #e3e4e8;
|
||||
--syn-property: #e0a0d6;
|
||||
--syn-constant: #e0a0d6;
|
||||
@@ -1087,6 +1090,7 @@ html {
|
||||
--syn-number: var(--palette-number);
|
||||
--syn-punct: var(--palette-fg);
|
||||
--syn-variable: var(--palette-variable);
|
||||
--syn-global: var(--syn-variable);
|
||||
--syn-param: var(--palette-fg);
|
||||
--syn-property: var(--palette-property);
|
||||
--syn-constant: var(--palette-number);
|
||||
|
||||
@@ -133,6 +133,10 @@ interface views {
|
||||
/// `program` (pinned by a `process:<program>` grant) with `args` in the
|
||||
/// project root: long-running output (logs) and interactive sessions.
|
||||
open-terminal: func(title: string, program: string, args: list<string>) -> result<_, error>;
|
||||
/// Opens `path` (project-relative or absolute, inside the project) in an
|
||||
/// editor of the window the call came from, the caret at `line` and
|
||||
/// `column` (0-based; UTF-16 column): a view's links to code.
|
||||
open-location: func(path: string, line: u32, column: u32) -> result<_, error>;
|
||||
}
|
||||
|
||||
/// The plugin's own settings (`[contributes.settings]`), as the user set them
|
||||
|
||||
Reference in new issue
Block a user