mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58afe0b154 | ||
|
|
b70c01d1d6 | ||
|
|
653ebd578a | ||
|
|
6e6baf4284 | ||
|
|
de09ee0678 | ||
|
|
292dc3cade | ||
|
|
d4a5b54720 | ||
|
|
c495c09416 | ||
|
|
324dded5c1 | ||
|
|
36fd117b29 | ||
|
|
9fb38769ad | ||
|
|
087cb4cbaa | ||
|
|
f6a1823121 | ||
|
|
4959b430b1 | ||
|
|
915fec598f | ||
|
|
94576eb879 | ||
|
|
8c915664e9 | ||
|
|
253fcfb7e0 | ||
|
|
24cf1a52d5 | ||
|
|
7071fd9107 | ||
|
|
df27de217e | ||
|
|
bff5d11346 | ||
|
|
9a48426740 | ||
|
|
dadec04d62 |
No files matched your search
@@ -722,6 +722,10 @@ pub struct PhpSettings {
|
||||
/// Intelephense stub sets loaded on top of its defaults and the `ext-*` entries of
|
||||
/// `composer.json` (`wordpress`).
|
||||
pub stubs: Vec<String>,
|
||||
/// PHPStan configuration file for checking saved files; empty = the `phpstan.neon`,
|
||||
/// `phpstan.neon.dist` or `phpstan.dist.neon` PHPStan finds in the project root. Absolute,
|
||||
/// `~/`, or relative to the project.
|
||||
pub phpstan_config: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -30,6 +30,21 @@ pub fn search_path() -> OsString {
|
||||
std::env::join_paths(dirs).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Ruby code printing where `gem install` puts executables: the Ruby's own `Gem.bindir`, which
|
||||
/// Homebrew's Ruby keeps off `PATH`, and the `--user-install` one.
|
||||
pub const RUBY_GEM_BIN_SCRIPT: &str = "puts Gem.bindir, File.join(Gem.user_dir, 'bin')";
|
||||
|
||||
/// The directories [`RUBY_GEM_BIN_SCRIPT`] printed that exist.
|
||||
pub fn gem_bin_dirs(output: &str) -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = Vec::new();
|
||||
for dir in output.lines().map(str::trim).map(PathBuf::from) {
|
||||
if dir.is_absolute() && dir.is_dir() && !dirs.contains(&dir) {
|
||||
dirs.push(dir);
|
||||
}
|
||||
}
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Resolves a command on the shared path, or an explicit path relative to `root`.
|
||||
pub fn find_command(command: &str, search: &OsStr, root: &Path) -> Option<PathBuf> {
|
||||
let path = Path::new(command);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! "Import a report collected in CI from disk": the format is sniffed from the
|
||||
//! content (lcov, Go cover profile, JaCoCo XML) and paths recorded on another
|
||||
//! content (lcov, Go cover profile, JaCoCo XML, SimpleCov resultset) and paths recorded on another
|
||||
//! machine are mapped into the project by their longest existing suffix.
|
||||
|
||||
use crate::{Accumulator, CoverageSuite, goprofile, jacoco, lcov};
|
||||
use crate::{Accumulator, CoverageSuite, goprofile, jacoco, lcov, simplecov};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
@@ -11,6 +11,7 @@ pub(crate) enum Format {
|
||||
Lcov,
|
||||
GoProfile,
|
||||
Jacoco,
|
||||
SimpleCov,
|
||||
}
|
||||
|
||||
pub(crate) fn detect(text: &str) -> Option<Format> {
|
||||
@@ -18,6 +19,9 @@ pub(crate) fn detect(text: &str) -> Option<Format> {
|
||||
if head.starts_with('<') {
|
||||
return head.contains("<report").then_some(Format::Jacoco);
|
||||
}
|
||||
if head.starts_with('{') {
|
||||
return head.contains("\"coverage\"").then_some(Format::SimpleCov);
|
||||
}
|
||||
let first = head.lines().next().unwrap_or_default();
|
||||
if first.starts_with("mode:") {
|
||||
return Some(Format::GoProfile);
|
||||
@@ -30,6 +34,7 @@ pub fn import(path: &Path, root: &Path) -> IdeResult<CoverageSuite> {
|
||||
let text = std::fs::read_to_string(path)?;
|
||||
let mut suite = match detect(&text) {
|
||||
Some(Format::Lcov) => lcov::parse(&text, root).finish("lcov"),
|
||||
Some(Format::SimpleCov) => simplecov::parse(&text, root)?.finish("SimpleCov"),
|
||||
Some(Format::GoProfile) => {
|
||||
let (module, module_dir) = go_module_for(root, &text);
|
||||
goprofile::parse(&text, &module, &module_dir).finish("go test -cover")
|
||||
@@ -50,7 +55,7 @@ pub fn import(path: &Path, root: &Path) -> IdeResult<CoverageSuite> {
|
||||
}
|
||||
None => {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"{} is not an lcov tracefile, Go cover profile or JaCoCo XML report",
|
||||
"{} is not an lcov tracefile, Go cover profile, JaCoCo XML report or SimpleCov resultset",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
@@ -125,9 +130,29 @@ mod tests {
|
||||
Some(Format::Jacoco)
|
||||
);
|
||||
assert_eq!(detect("<?xml version=\"1.0\"?><coverage/>"), None);
|
||||
assert_eq!(detect("{\"RSpec\": {\"coverage\": {}}}"), Some(Format::SimpleCov));
|
||||
assert_eq!(detect("{\"name\": \"package.json\"}"), None);
|
||||
assert_eq!(detect("hello"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_ci_simplecov_with_foreign_paths() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("app");
|
||||
std::fs::create_dir_all(root.join("lib")).unwrap();
|
||||
std::fs::write(root.join("lib/a.rb"), "").unwrap();
|
||||
let report = tmp.path().join(".resultset.json");
|
||||
std::fs::write(
|
||||
&report,
|
||||
r#"{"RSpec":{"coverage":{"/home/runner/work/app/app/lib/a.rb":{"lines":[null,1,0]}}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let suite = import(&report, &root).unwrap();
|
||||
assert_eq!(suite.runner, "SimpleCov");
|
||||
assert_eq!(suite.files[0].path.as_ref() as &Path, root.join("lib/a.rb"));
|
||||
assert_eq!((suite.files[0].lines_total, suite.files[0].lines_covered), (2, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_ci_lcov_with_foreign_paths() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
//! | JS / TS (vitest, jest scripts) | the runner's `--coverage` | lcov |
|
||||
//! | Node.js scripts | `c8` | lcov |
|
||||
//! | Python (pytest, scripts) | coverage.py (`coverage run`, then `coverage lcov`) | lcov |
|
||||
//! | Ruby (rspec, minitest, rake, scripts) | SimpleCov (`coverage/.resultset.json`) | SimpleCov resultset |
|
||||
//! | Go (`go test`) | `-coverprofile` | Go cover profile |
|
||||
//! | Maven / Gradle tests | JaCoCo (plugin goals / an init script) | JaCoCo XML |
|
||||
//!
|
||||
@@ -20,6 +21,7 @@ mod import;
|
||||
mod jacoco;
|
||||
mod lcov;
|
||||
mod plan;
|
||||
mod simplecov;
|
||||
|
||||
pub use import::import;
|
||||
pub use plan::prepare;
|
||||
@@ -83,6 +85,8 @@ pub enum CoverageReportSpec {
|
||||
Lcov { runner: String, path: IpcPath, base: IpcPath },
|
||||
/// `go test -coverprofile`; import paths under `module` map to `module_dir`.
|
||||
GoProfile { path: IpcPath, module: String, module_dir: IpcPath },
|
||||
/// SimpleCov's `.resultset.json`; relative paths resolve against `base`.
|
||||
SimpleCov { path: IpcPath, base: IpcPath },
|
||||
/// Every JaCoCo XML report (Maven `target/site/jacoco`, Gradle `build/reports/jacoco`) under `dir`.
|
||||
Jacoco { dir: IpcPath },
|
||||
/// The tool's raw data is exported to lcov by `argv` first (coverage.py: `… -m coverage lcov -o <lcov>`).
|
||||
@@ -185,6 +189,10 @@ pub fn collect(spec: &CoverageReportSpec) -> IdeResult<CoverageSuite> {
|
||||
let text = read_report(path)?;
|
||||
Ok(lcov::parse(&text, base).finish(runner))
|
||||
}
|
||||
CoverageReportSpec::SimpleCov { path, base } => {
|
||||
let text = read_report(path)?;
|
||||
Ok(simplecov::parse(&text, base)?.finish("SimpleCov"))
|
||||
}
|
||||
CoverageReportSpec::GoProfile { path, module, module_dir } => {
|
||||
let text = read_report(path)?;
|
||||
Ok(goprofile::parse(&text, module, module_dir).finish("go test -cover"))
|
||||
|
||||
@@ -67,6 +67,7 @@ pub fn prepare(root: &Path, config: &RunConfig, out: &Path) -> CoveragePrepared
|
||||
"gradle" | "gradlew" => gradle(config, &dir, out),
|
||||
"npm" | "pnpm" | "yarn" | "bun" => npm(root, config, &dir, out),
|
||||
"node" | "npx" | "tsx" | "ts-node" => c8(root, config, &dir, out),
|
||||
"ruby" | "bundle" | "rake" | "rspec" | "rails" => ruby(root, config, &dir, out),
|
||||
"deno" => unsupported("Coverage of Deno tasks is not supported yet."),
|
||||
"java" => unsupported(
|
||||
"Coverage of Java applications needs the JaCoCo agent; run a Maven or Gradle test configuration with coverage.",
|
||||
@@ -234,6 +235,65 @@ fn c8(root: &Path, config: &RunConfig, dir: &Path, out: &Path) -> CoveragePrepar
|
||||
ready("c8", config, path(&c8), args, report)
|
||||
}
|
||||
|
||||
/// Loaded first (`ide_run::ruby_preload`) when the project never starts SimpleCov itself.
|
||||
const SIMPLECOV_PRELOAD: &str = r#"# Written by OpenIDEA for Run with Coverage.
|
||||
begin
|
||||
require "simplecov"
|
||||
rescue LoadError
|
||||
# `bundle exec` is a Ruby process too and loads this before Bundler puts the project's gems on
|
||||
# the load path; the Ruby it starts loads bundler/setup first and finds SimpleCov.
|
||||
return
|
||||
end
|
||||
SimpleCov.coverage_dir(ENV.fetch("OPENIDEA_COVERAGE_DIR"))
|
||||
SimpleCov.start do
|
||||
add_filter %r{^/(test|spec|features)/}
|
||||
enable_coverage :branch
|
||||
end
|
||||
"#;
|
||||
|
||||
/// Files where a project conventionally starts SimpleCov.
|
||||
const SIMPLECOV_STARTERS: &[&str] =
|
||||
&[".simplecov", "spec/spec_helper.rb", "spec/rails_helper.rb", "test/test_helper.rb", "test/minitest_helper.rb"];
|
||||
|
||||
fn ruby(root: &Path, config: &RunConfig, dir: &Path, out: &Path) -> CoveragePrepared {
|
||||
let bundled = [dir, root].iter().any(|d| d.join("Gemfile").is_file());
|
||||
let mentions = |file: &str| {
|
||||
[dir, root].iter().any(|d| std::fs::read_to_string(d.join(file)).is_ok_and(|t| t.contains("simplecov")))
|
||||
};
|
||||
if bundled && !mentions("Gemfile.lock") && !mentions("Gemfile") {
|
||||
return missing(
|
||||
"simplecov",
|
||||
"Ruby coverage needs the simplecov gem in the project's Gemfile.",
|
||||
Some((vec![s("bundle"), s("add"), s("simplecov"), s("--group"), s("test")], dir)),
|
||||
);
|
||||
}
|
||||
let starts = |d: &Path| {
|
||||
SIMPLECOV_STARTERS.iter().any(|f| {
|
||||
std::fs::read_to_string(d.join(f))
|
||||
.is_ok_and(|t| t.contains("SimpleCov.start") || f.starts_with(".simplecov"))
|
||||
})
|
||||
};
|
||||
let mut config = config.clone();
|
||||
let resultset = if [dir, root].iter().any(|d| starts(d)) {
|
||||
// The project's own configuration decides where the report goes (default `coverage/`).
|
||||
// Drop the previous run's data: SimpleCov merges into it.
|
||||
let results = dir.join("coverage");
|
||||
let _ = std::fs::remove_file(results.join(".resultset.json"));
|
||||
results.join(".resultset.json")
|
||||
} else {
|
||||
let preload = out.join("openidea_simplecov.rb");
|
||||
if let Err(e) = std::fs::write(&preload, SIMPLECOV_PRELOAD) {
|
||||
return unsupported(format!("Cannot write the SimpleCov preload: {e}"));
|
||||
}
|
||||
config.env.insert(s("OPENIDEA_COVERAGE_DIR"), path(out));
|
||||
// `out` lives under the IDE's data directory, which may have spaces (RUBYOPT cannot take them).
|
||||
ide_run::ruby_preload::add(&mut config.env, out, "openidea_simplecov", true);
|
||||
out.join(".resultset.json")
|
||||
};
|
||||
let report = CoverageReportSpec::SimpleCov { path: resultset.into(), base: dir.to_path_buf().into() };
|
||||
ready("SimpleCov", &config, config.command.clone(), config.args.clone(), report)
|
||||
}
|
||||
|
||||
fn python(config: &RunConfig, dir: &Path, out: &Path, py: wrap::PythonCommand) -> CoveragePrepared {
|
||||
if py.target.is_empty() {
|
||||
return unsupported("Coverage needs a Python script or module to run.");
|
||||
@@ -398,6 +458,74 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// `bundle exec` loads the preload before Bundler sets up the project's gems: without SimpleCov
|
||||
/// on the load path it must step aside instead of failing the launcher.
|
||||
#[test]
|
||||
fn simplecov_preload_tolerates_a_ruby_without_the_gem() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let out = dir.path().join("out dir");
|
||||
std::fs::create_dir_all(&out).unwrap();
|
||||
std::fs::write(out.join("openidea_simplecov.rb"), SIMPLECOV_PRELOAD).unwrap();
|
||||
let status = std::process::Command::new("ruby")
|
||||
.args(["-e", "exit 0"])
|
||||
.env("RUBYLIB", &out)
|
||||
.env("RUBYOPT", "-ropenidea_simplecov")
|
||||
.env("OPENIDEA_COVERAGE_DIR", &out)
|
||||
.env("GEM_HOME", dir.path().join("empty gems"))
|
||||
.env("GEM_PATH", dir.path().join("empty gems"))
|
||||
.status();
|
||||
// No Ruby on this machine: nothing to check.
|
||||
let Ok(status) = status else { return };
|
||||
assert!(status.success());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruby_runs_get_simplecov() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
// The IDE's data directory has spaces on macOS (`Application Support`).
|
||||
let out = root.join("out dir");
|
||||
std::fs::create_dir_all(&out).unwrap();
|
||||
std::fs::write(root.join("Gemfile"), "source 'https://rubygems.org'\n").unwrap();
|
||||
let config = cfg("rspec", "bundle", &["exec", "rspec"]);
|
||||
// Bundler project without the gem: install hint.
|
||||
match prepare(root, &config, &out) {
|
||||
CoveragePrepared::MissingTool { tool, install: Some(i), .. } => {
|
||||
assert_eq!(tool, "simplecov");
|
||||
assert_eq!(i.args, ["add", "simplecov", "--group", "test"]);
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
// Gem present, never started: preloaded through RUBYOPT, report in `out`.
|
||||
std::fs::write(root.join("Gemfile"), "gem 'simplecov'\n").unwrap();
|
||||
let p = prepare(root, &config, &out);
|
||||
let (l, report) = launch(&p);
|
||||
assert_eq!(
|
||||
(l.command.as_str(), l.args.as_slice()),
|
||||
("bundle", ["exec".to_owned(), "rspec".to_owned()].as_slice())
|
||||
);
|
||||
assert!(l.env["RUBYOPT"].contains("-ropenidea_simplecov"));
|
||||
assert!(std::env::split_paths(&l.env["RUBYLIB"]).any(|d| d == out));
|
||||
assert_eq!(l.env["OPENIDEA_COVERAGE_DIR"], out.to_string_lossy());
|
||||
assert!(out.join("openidea_simplecov.rb").is_file());
|
||||
assert!(
|
||||
matches!(report, CoverageReportSpec::SimpleCov { path, .. } if (path.as_ref() as &Path).starts_with(&out))
|
||||
);
|
||||
// Started by the project: untouched environment, stale data dropped, report in `coverage/`.
|
||||
std::fs::create_dir_all(root.join("test")).unwrap();
|
||||
std::fs::create_dir_all(root.join("coverage")).unwrap();
|
||||
std::fs::write(root.join("coverage/.resultset.json"), "{}").unwrap();
|
||||
std::fs::write(root.join("test/test_helper.rb"), "require 'simplecov'\nSimpleCov.start\n").unwrap();
|
||||
let p = prepare(root, &cfg("ruby", "ruby", &["-Ilib", "test/a_test.rb"]), &out);
|
||||
let (l, report) = launch(&p);
|
||||
assert!(!l.env.contains_key("RUBYOPT"));
|
||||
assert_eq!(l.args, ["-Ilib", "test/a_test.rb"]);
|
||||
assert!(!root.join("coverage/.resultset.json").exists());
|
||||
assert!(
|
||||
matches!(report, CoverageReportSpec::SimpleCov { path, .. } if (path.as_ref() as &Path).ends_with("coverage/.resultset.json"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_commands_are_unsupported() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! SimpleCov's `coverage/.resultset.json`:
|
||||
//!
|
||||
//! ```json
|
||||
//! { "RSpec": { "timestamp": 1, "coverage": {
|
||||
//! "/abs/lib/a.rb": { "lines": [null, 1, 0],
|
||||
//! "branches": { "[:if, 0, 3, 4, 3, 20]": { "[:then, 1, 4, 6, 4, 9]": 1, "[:else, 2, 6, 6, 6, 9]": 0 } } },
|
||||
//! "/abs/lib/legacy.rb": [null, 1] } } }
|
||||
//! ```
|
||||
//!
|
||||
//! `lines[i]` is line `i + 1` (`null` = not relevant), so the index is already 0-based.
|
||||
//! A branch belongs to the line its condition starts on (third number of its key).
|
||||
|
||||
use crate::Accumulator;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde_json::Value;
|
||||
use std::path::Path;
|
||||
|
||||
/// Start line (1-based) of a `"[:if, 0, 3, 4, 3, 20]"` branch key.
|
||||
fn start_line(key: &str) -> Option<u32> {
|
||||
key.trim_matches(['[', ']']).split(',').nth(2)?.trim().parse().ok()
|
||||
}
|
||||
|
||||
pub(crate) fn parse(text: &str, base: &Path) -> IdeResult<Accumulator> {
|
||||
let json: Value = serde_json::from_str(text).map_err(|e| IdeError::invalid(format!("SimpleCov resultset: {e}")))?;
|
||||
let mut acc = Accumulator::default();
|
||||
// One entry per test command ("RSpec", "Minitest", …); their coverage is merged.
|
||||
for suite in json.as_object().into_iter().flat_map(|o| o.values()) {
|
||||
let Some(files) = suite.get("coverage").and_then(Value::as_object) else { continue };
|
||||
for (name, data) in files {
|
||||
let p = Path::new(name);
|
||||
let path = if p.is_absolute() { p.to_path_buf() } else { base.join(p) };
|
||||
acc.file(&path);
|
||||
let lines = data.get("lines").or(Some(data)).and_then(Value::as_array);
|
||||
for (i, hits) in lines.into_iter().flatten().enumerate() {
|
||||
if let Some(hits) = hits.as_u64() {
|
||||
acc.line(&path, i as u32, hits);
|
||||
}
|
||||
}
|
||||
for (cond, branches) in data.get("branches").and_then(Value::as_object).into_iter().flatten() {
|
||||
let Some(line) = start_line(cond) else { continue };
|
||||
for (branch, hits) in branches.as_object().into_iter().flatten() {
|
||||
acc.has_branches = true;
|
||||
acc.branch(&path, line.saturating_sub(1), format!("{cond}/{branch}"), hits.as_u64() > Some(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(acc)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_lines_branches_and_merges_suites() {
|
||||
let text = r#"{
|
||||
"RSpec": {"coverage": {"/p/lib/a.rb": {"lines": [null, 1, 0],
|
||||
"branches": {"[:if, 0, 2, 4, 2, 20]": {"[:then, 1, 2, 6, 2, 9]": 1, "[:else, 2, 2, 6, 2, 9]": 0}}}}},
|
||||
"Minitest": {"coverage": {"/p/lib/a.rb": {"lines": [null, 2, 3]}, "lib/old.rb": [1, null]}}
|
||||
}"#;
|
||||
let suite = parse(text, Path::new("/p")).unwrap().finish("SimpleCov");
|
||||
assert_eq!(suite.files.len(), 2);
|
||||
let a = suite.files.iter().find(|f| (f.path.as_ref() as &Path).ends_with("a.rb")).unwrap();
|
||||
assert_eq!(a.lines.iter().map(|l| (l.line, l.hits)).collect::<Vec<_>>(), [(1, 3), (2, 3)]);
|
||||
assert_eq!((a.branches_total, a.branches_covered), (2, 1));
|
||||
assert_eq!(a.lines[0].branches_total, 2);
|
||||
let old = suite.files.iter().find(|f| (f.path.as_ref() as &Path).ends_with("lib/old.rb")).unwrap();
|
||||
assert_eq!(old.path.as_ref() as &Path, Path::new("/p/lib/old.rb"));
|
||||
assert_eq!(old.lines[0].hits, 1);
|
||||
assert!(suite.has_branches);
|
||||
assert!(parse("nope", Path::new("/p")).is_err());
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ mod java_runtime;
|
||||
mod kotlin_bridge;
|
||||
pub mod notebook;
|
||||
pub mod ops;
|
||||
mod phpstan;
|
||||
pub mod plugins;
|
||||
mod project;
|
||||
mod proxy;
|
||||
@@ -228,6 +229,9 @@ impl Host {
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.stop();
|
||||
}
|
||||
if let Some(phpstan) = &project.phpstan {
|
||||
phpstan.stop();
|
||||
}
|
||||
project.documents.clear_workspace_edit_history();
|
||||
project.agents.shutdown();
|
||||
project.notebooks.stop_admission();
|
||||
@@ -349,6 +353,9 @@ impl Host {
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.stop();
|
||||
}
|
||||
if let Some(phpstan) = &project.phpstan {
|
||||
phpstan.stop();
|
||||
}
|
||||
project.documents.clear_workspace_edit_history();
|
||||
project.notebooks.stop_admission();
|
||||
project.agents.shutdown();
|
||||
|
||||
@@ -32,7 +32,7 @@ pub async fn coverage_collect(host: &Host, window: &str, report: CoverageReportS
|
||||
blocking(move || ide_coverage::collect(&report)).await
|
||||
}
|
||||
|
||||
/// Reads a report collected elsewhere (CI): lcov, a Go cover profile or JaCoCo XML,
|
||||
/// Reads a report collected elsewhere (CI): lcov, a Go cover profile, JaCoCo XML or a SimpleCov resultset,
|
||||
/// with paths mapped into this project.
|
||||
pub async fn coverage_import(host: &Host, window: &str, path: IpcPath) -> IdeResult<CoverageSuite> {
|
||||
let root = host.project(window)?.root.clone();
|
||||
|
||||
@@ -198,6 +198,7 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
|
||||
let options = StartOptions {
|
||||
encoding: Some(encoding).filter(|e| !e.trim().is_empty()),
|
||||
python_paths: if *root != project.root { vec![root.clone()] } else { super::python::paths(&project) },
|
||||
ruby_reporter: ruby_reporter_dir(host, &config),
|
||||
};
|
||||
project.require_trust()?;
|
||||
let mut sink = project.sink.clone();
|
||||
@@ -214,6 +215,21 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
|
||||
host.processes.start(&project.root, &config, &options, sink, Box::new(move |c| on_output(c)))
|
||||
}
|
||||
|
||||
/// The Ruby test reporter's directory, installed on demand, for runs that take it.
|
||||
fn ruby_reporter_dir(host: &Host, config: &RunConfig) -> Option<std::path::PathBuf> {
|
||||
if !ide_run::ruby_reporter::applies(config) {
|
||||
return None;
|
||||
}
|
||||
let dir = host.local_data_dir.join("ruby-reporter");
|
||||
match ide_run::ruby_reporter::install(&dir) {
|
||||
Ok(()) => Some(dir),
|
||||
Err(e) => {
|
||||
tracing::warn!("cannot install the Ruby test reporter in {}: {e}", dir.display());
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops a run (interrupt, then kill after Settings › Run's termination timeout).
|
||||
pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> {
|
||||
let settings = match host.project(window) {
|
||||
|
||||
@@ -24,7 +24,11 @@ fn tools(host: &Host, window: &str) -> IdeResult<Tools> {
|
||||
.collect();
|
||||
// Godot is usually an app bundle / Flatpak outside the search path.
|
||||
let found = ide_sdk::godot::find(None).map(|p| (ide_sdk::godot::COMMAND.to_owned(), p));
|
||||
Ok(Tools::new(&data).with_custom_paths(custom).with_found_paths(found.into_iter().collect()))
|
||||
let work_dir = (!root.as_os_str().is_empty()).then_some(root);
|
||||
Ok(Tools::new(&data)
|
||||
.with_custom_paths(custom)
|
||||
.with_found_paths(found.into_iter().collect())
|
||||
.with_work_dir(work_dir))
|
||||
}
|
||||
|
||||
/// The language definitions plus the servers defined in the window's settings.
|
||||
|
||||
@@ -0,0 +1,850 @@
|
||||
//! Saved PHP files -> `vendor/bin/phpstan analyse` -> `phpstan` diagnostics.
|
||||
|
||||
use ide_core::event::NotifyLevel;
|
||||
use ide_core::output_encoding::decode_tool_output;
|
||||
use ide_core::{CancellationToken, Diagnostic, IdeEvent, Pos, Range, Severity, SharedSink};
|
||||
use ide_workspace::SettingsStore;
|
||||
use ide_workspace::documents::DocumentObserver;
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::Value;
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Notify;
|
||||
|
||||
const DELAY: Duration = Duration::from_millis(750);
|
||||
const SOURCE: &str = "phpstan";
|
||||
|
||||
/// Saved files waiting for a check. Each save cancels the wait or the check in progress.
|
||||
#[derive(Default)]
|
||||
struct Queue {
|
||||
generation: u64,
|
||||
cancel: CancellationToken,
|
||||
files: BTreeSet<PathBuf>,
|
||||
}
|
||||
|
||||
impl Queue {
|
||||
fn push(&mut self, files: impl IntoIterator<Item = PathBuf>) {
|
||||
self.files.extend(files);
|
||||
self.cancel.cancel();
|
||||
self.generation += 1;
|
||||
self.cancel = CancellationToken::new();
|
||||
}
|
||||
fn current(&self, generation: u64) -> bool {
|
||||
self.generation == generation && !self.cancel.is_cancelled()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Phpstan {
|
||||
root: PathBuf,
|
||||
config_dir: PathBuf,
|
||||
settings: Arc<SettingsStore>,
|
||||
sink: SharedSink,
|
||||
trusted: Arc<AtomicBool>,
|
||||
lifetime: CancellationToken,
|
||||
queue: Mutex<Queue>,
|
||||
changed: Notify,
|
||||
/// Files with PHPStan diagnostics, the only ones a clean result or a removal clears.
|
||||
shown: Mutex<HashSet<PathBuf>>,
|
||||
}
|
||||
|
||||
impl Phpstan {
|
||||
pub(crate) fn new(
|
||||
root: PathBuf,
|
||||
config_dir: PathBuf,
|
||||
settings: Arc<SettingsStore>,
|
||||
sink: SharedSink,
|
||||
trusted: Arc<AtomicBool>,
|
||||
) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
root,
|
||||
config_dir,
|
||||
settings,
|
||||
sink,
|
||||
trusted,
|
||||
lifetime: CancellationToken::new(),
|
||||
queue: Mutex::new(Queue::default()),
|
||||
changed: Notify::new(),
|
||||
shown: Mutex::new(HashSet::new()),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn start(self: &Arc<Self>, runtime: &tokio::runtime::Handle) {
|
||||
let checker = self.clone();
|
||||
runtime.spawn(async move { checker.run().await });
|
||||
}
|
||||
|
||||
pub(crate) fn stop(&self) {
|
||||
self.lifetime.cancel();
|
||||
self.queue.lock().cancel.cancel();
|
||||
}
|
||||
|
||||
/// Stops the check in progress (trust changed); its files wait for the next one, which drops
|
||||
/// them while the project is in Restricted Mode.
|
||||
pub(crate) fn interrupt(&self) {
|
||||
self.queue.lock().push([]);
|
||||
self.changed.notify_one();
|
||||
}
|
||||
|
||||
fn files_saved(&self, paths: impl IntoIterator<Item = PathBuf>) {
|
||||
let mut files = paths.into_iter().filter(|p| p.extension().is_some_and(|e| e == "php")).peekable();
|
||||
if self.lifetime.is_cancelled() || files.peek().is_none() {
|
||||
return;
|
||||
}
|
||||
self.queue.lock().push(files);
|
||||
self.changed.notify_one();
|
||||
}
|
||||
|
||||
/// Deleted files and folders lose their diagnostics.
|
||||
pub(crate) fn files_removed<'a>(&self, removed: impl Iterator<Item = &'a PathBuf>) {
|
||||
let removed: Vec<&PathBuf> = removed.collect();
|
||||
let gone: Vec<PathBuf> =
|
||||
self.shown.lock().iter().filter(|p| removed.iter().any(|r| p.starts_with(r))).cloned().collect();
|
||||
for path in gone {
|
||||
self.publish(path, Vec::new());
|
||||
}
|
||||
}
|
||||
|
||||
fn publish(&self, path: PathBuf, items: Vec<Diagnostic>) {
|
||||
let mut shown = self.shown.lock();
|
||||
if items.is_empty() {
|
||||
if !shown.remove(&path) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
shown.insert(path.clone());
|
||||
}
|
||||
// Under the lock, so a removal and a result for one file reach the UI in the order decided here.
|
||||
self.sink.emit(IdeEvent::Diagnostics { path: path.into(), source: SOURCE.into(), items });
|
||||
}
|
||||
|
||||
async fn run(self: Arc<Self>) {
|
||||
// The failure last reported per root: each is reported once, until a run succeeds.
|
||||
let mut failures: BTreeMap<PathBuf, String> = BTreeMap::new();
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = self.lifetime.cancelled() => return,
|
||||
_ = self.changed.notified() => {},
|
||||
}
|
||||
let (generation, cancel) = {
|
||||
let queue = self.queue.lock();
|
||||
(queue.generation, queue.cancel.clone())
|
||||
};
|
||||
if !debounce(&cancel, &self.lifetime).await {
|
||||
continue;
|
||||
}
|
||||
let files = {
|
||||
let mut queue = self.queue.lock();
|
||||
if !queue.current(generation) {
|
||||
continue;
|
||||
}
|
||||
std::mem::take(&mut queue.files)
|
||||
};
|
||||
if !self.trusted.load(Ordering::Acquire) {
|
||||
continue;
|
||||
}
|
||||
let (root, config_dir, settings) = (self.root.clone(), self.config_dir.clone(), self.settings.clone());
|
||||
let Ok((checks, unchecked)) =
|
||||
tokio::task::spawn_blocking(move || plan(&root, &config_dir, &settings, files)).await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for path in unchecked {
|
||||
self.publish(path, Vec::new());
|
||||
}
|
||||
let mut checks = checks.into_iter();
|
||||
while let Some(check) = checks.next() {
|
||||
let started = std::time::Instant::now();
|
||||
let program = check.command.get_program().to_string_lossy().into_owned();
|
||||
let output = analyse(check.command, &cancel).await;
|
||||
let Some(output) = output.filter(|_| self.queue.lock().current(generation)) else {
|
||||
// A newer save: these files are checked again with it.
|
||||
let mut queue = self.queue.lock();
|
||||
queue.files.extend(check.files);
|
||||
queue.files.extend(checks.by_ref().flat_map(|c| c.files));
|
||||
break;
|
||||
};
|
||||
tracing::debug!(
|
||||
"phpstan checked {} file(s) in {} ms",
|
||||
check.files.len(),
|
||||
started.elapsed().as_millis()
|
||||
);
|
||||
// Files deleted meanwhile make the result (or the failure) moot: check the rest again.
|
||||
let (present, missing): (Vec<PathBuf>, Vec<PathBuf>) =
|
||||
check.files.iter().cloned().partition(|f| f.is_file());
|
||||
if !missing.is_empty() {
|
||||
for path in missing {
|
||||
self.publish(path, Vec::new());
|
||||
}
|
||||
self.files_saved(present);
|
||||
continue;
|
||||
}
|
||||
let report = match output {
|
||||
Ok(output) => {
|
||||
let parsed = parse(&decode_tool_output(&output.stdout), &decode_tool_output(&output.stderr));
|
||||
parsed.map_err(|text| {
|
||||
if text.is_empty() {
|
||||
let status = output.status;
|
||||
format!("PHPStan ended with {status} and no report")
|
||||
} else {
|
||||
text
|
||||
}
|
||||
})
|
||||
}
|
||||
Err(error) => Err(format!("Cannot run {program}: {error}")),
|
||||
};
|
||||
let failure = match report {
|
||||
Ok(report) => {
|
||||
let files = check.files.clone();
|
||||
let results = tokio::task::spawn_blocking(move || diagnostics(&files, report.files))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
for (path, items) in results {
|
||||
// Severe errors (a syntax error) stop the analysis of the other files.
|
||||
if report.complete || !items.is_empty() {
|
||||
self.publish(path, items);
|
||||
}
|
||||
}
|
||||
report.errors.join("\n")
|
||||
}
|
||||
Err(text) => {
|
||||
// Without a report the earlier errors of these files may be stale.
|
||||
for path in check.files {
|
||||
self.publish(path, Vec::new());
|
||||
}
|
||||
text
|
||||
}
|
||||
};
|
||||
if failure.is_empty() {
|
||||
failures.remove(&check.root);
|
||||
} else {
|
||||
self.failure(&check.root, failure, &mut failures);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn failure(&self, root: &Path, text: String, failures: &mut BTreeMap<PathBuf, String>) {
|
||||
if failures.get(root) == Some(&text) {
|
||||
return;
|
||||
}
|
||||
self.sink.emit(IdeEvent::Notify {
|
||||
level: NotifyLevel::Error,
|
||||
title: "PHPStan check failed".into(),
|
||||
body: Some(text.clone()),
|
||||
});
|
||||
failures.insert(root.to_path_buf(), text);
|
||||
}
|
||||
}
|
||||
|
||||
impl DocumentObserver for Phpstan {
|
||||
fn saved(&self, _doc: ide_core::DocId, path: &Path, _text: &str) {
|
||||
self.files_saved([path.to_path_buf()]);
|
||||
}
|
||||
}
|
||||
|
||||
async fn debounce(cancel: &CancellationToken, lifetime: &CancellationToken) -> bool {
|
||||
tokio::select! {
|
||||
_ = lifetime.cancelled() => false,
|
||||
_ = cancel.cancelled() => false,
|
||||
_ = tokio::time::sleep(DELAY) => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// One `vendor/bin/phpstan` run: the saved files below one PHP project.
|
||||
struct Check {
|
||||
root: PathBuf,
|
||||
files: Vec<PathBuf>,
|
||||
command: Command,
|
||||
}
|
||||
|
||||
/// The runs that check `files`, and the files not checked, whose earlier diagnostics no longer
|
||||
/// apply. A file belongs to the innermost linked project (with its own settings) that contains it,
|
||||
/// else to the project; deleted files, files in `vendor` or in excluded folders, and files of a
|
||||
/// project without PHPStan are not checked.
|
||||
fn plan(
|
||||
project: &Path,
|
||||
config_dir: &Path,
|
||||
settings: &SettingsStore,
|
||||
files: BTreeSet<PathBuf>,
|
||||
) -> (Vec<Check>, Vec<PathBuf>) {
|
||||
let members =
|
||||
ide_workspace::workspace::Workspace::load(project).ok().flatten().map(|w| w.projects).unwrap_or_default();
|
||||
let mut groups: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
|
||||
let mut unchecked = Vec::new();
|
||||
for file in files {
|
||||
let member = members.iter().filter(|m| file.starts_with(m)).max_by_key(|m| m.components().count());
|
||||
let root = member.cloned().or_else(|| file.starts_with(project).then(|| project.to_path_buf()));
|
||||
match root {
|
||||
Some(root) if file.is_file() => groups.entry(root).or_default().push(file),
|
||||
_ => unchecked.push(file),
|
||||
}
|
||||
}
|
||||
let mut checks = Vec::new();
|
||||
for (root, files) in groups {
|
||||
let effective = if root == project {
|
||||
settings.effective()
|
||||
} else {
|
||||
SettingsStore::load(config_dir, Some(&root)).effective()
|
||||
};
|
||||
let excludes = ide_vfs::ExcludeMatcher::new(&effective.exclude_globs());
|
||||
let (files, skipped): (Vec<PathBuf>, Vec<PathBuf>) = files.into_iter().partition(|f| {
|
||||
f.strip_prefix(&root).is_ok_and(|rel| !rel.starts_with("vendor") && !excludes.is_excluded(rel))
|
||||
});
|
||||
unchecked.extend(skipped);
|
||||
match ide_sdk::php::phpstan_command(&root, &effective, &files).filter(|_| !files.is_empty()) {
|
||||
Some(command) => checks.push(Check { root, files, command }),
|
||||
None => unchecked.extend(files),
|
||||
}
|
||||
}
|
||||
(checks, unchecked)
|
||||
}
|
||||
|
||||
/// Runs `cmd` to its end; `None` when `cancel` fires first, which kills it.
|
||||
async fn analyse(mut cmd: Command, cancel: &CancellationToken) -> Option<std::io::Result<Output>> {
|
||||
if cancel.is_cancelled() {
|
||||
return None;
|
||||
}
|
||||
ide_core::process::prepare(&mut cmd);
|
||||
ide_core::process::own_group(&mut cmd);
|
||||
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
let mut cmd = tokio::process::Command::from(cmd);
|
||||
cmd.kill_on_drop(true);
|
||||
let child = match cmd.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(error) => return Some(Err(error)),
|
||||
};
|
||||
let pid = child.id();
|
||||
// PHPStan may start workers of its own.
|
||||
let cancelled = async {
|
||||
cancel.cancelled().await;
|
||||
if let Some(pid) = pid {
|
||||
let _ = tokio::task::spawn_blocking(move || ide_core::process::kill_tree_of(pid)).await;
|
||||
}
|
||||
};
|
||||
tokio::select! {
|
||||
output = child.wait_with_output() => Some(output),
|
||||
() = cancelled => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A PHPStan message: 0-based line, text with its tip, identifier.
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Message {
|
||||
line: u32,
|
||||
text: String,
|
||||
identifier: Option<String>,
|
||||
}
|
||||
|
||||
/// A JSON report: messages per file as PHPStan names it, the errors of the run itself, and
|
||||
/// whether every file was analysed (severe errors such as a syntax error stop the others).
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Report {
|
||||
files: BTreeMap<String, Vec<Message>>,
|
||||
errors: Vec<String>,
|
||||
complete: bool,
|
||||
}
|
||||
|
||||
impl Default for Report {
|
||||
fn default() -> Self {
|
||||
Self { files: BTreeMap::new(), errors: Vec::new(), complete: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// PHPStan's `--error-format=json` report, or the text to report when there is none (an
|
||||
/// invalid configuration, a crash). PHP warnings, or a byte order mark a bootstrap file prints,
|
||||
/// may precede the report on stdout.
|
||||
fn parse(stdout: &str, stderr: &str) -> Result<Report, String> {
|
||||
let json = serde_json::from_str::<Value>(stdout.trim().trim_start_matches('\u{feff}')).ok().or_else(|| {
|
||||
stdout.lines().rev().find_map(|l| l.find('{').and_then(|start| serde_json::from_str(&l[start..]).ok()))
|
||||
});
|
||||
let Some(Value::Object(json)) = json else {
|
||||
// Every saved file is excluded by the configuration (`excludePaths`).
|
||||
if stderr.contains("No files found to analyse") {
|
||||
return Ok(Report::default());
|
||||
}
|
||||
return Err(failure_text(stdout, stderr));
|
||||
};
|
||||
let mut report = Report::default();
|
||||
if let Some(Value::Object(files)) = json.get("files") {
|
||||
for (file, result) in files {
|
||||
let messages = result.get("messages").and_then(Value::as_array).into_iter().flatten();
|
||||
report.files.insert(file.clone(), messages.filter_map(message).collect());
|
||||
}
|
||||
}
|
||||
let errors = json.get("errors").and_then(Value::as_array).into_iter().flatten();
|
||||
report.errors = errors.map(|e| e.as_str().map_or_else(|| e.to_string(), str::to_owned)).collect();
|
||||
report.complete = !stderr.contains("Result is incomplete");
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn message(m: &Value) -> Option<Message> {
|
||||
let mut text = m.get("message")?.as_str()?.to_owned();
|
||||
if let Some(tip) = m.get("tip").and_then(Value::as_str).filter(|t| !t.trim().is_empty()) {
|
||||
text = format!("{text}\n{}", strip_console_tags(tip));
|
||||
}
|
||||
let line = m.get("line").and_then(Value::as_u64).unwrap_or(1).max(1) - 1;
|
||||
Some(Message {
|
||||
line: u32::try_from(line).unwrap_or(u32::MAX),
|
||||
text,
|
||||
identifier: m.get("identifier").and_then(Value::as_str).map(str::to_owned),
|
||||
})
|
||||
}
|
||||
|
||||
/// Tips may carry console styling (`<fg=cyan>…</>`).
|
||||
fn strip_console_tags(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut rest = text;
|
||||
while let Some(i) = rest.find('<') {
|
||||
out.push_str(&rest[..i]);
|
||||
rest = &rest[i..];
|
||||
let tag = rest.find('>').map(|end| &rest[..=end]);
|
||||
match tag {
|
||||
Some(tag)
|
||||
if tag == "</>"
|
||||
|| tag.starts_with("<fg=")
|
||||
|| tag.starts_with("<bg=")
|
||||
|| tag.starts_with("<options=") =>
|
||||
{
|
||||
rest = &rest[tag.len()..];
|
||||
}
|
||||
_ => {
|
||||
out.push('<');
|
||||
rest = &rest[1..];
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// What PHPStan printed instead of a report, without its notes and blank lines.
|
||||
fn failure_text(stdout: &str, stderr: &str) -> String {
|
||||
let lines: Vec<&str> = stderr
|
||||
.lines()
|
||||
.chain(stdout.lines())
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty() && !l.starts_with("Note: Using configuration file"))
|
||||
.collect();
|
||||
lines.iter().take(20).copied().collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
/// The diagnostics of each checked file (none for a clean one), matched to the report's files
|
||||
/// by their real paths. Errors PHPStan reports in a trait name the class using it
|
||||
/// (`Trait.php (in context of class Foo)`).
|
||||
fn diagnostics(files: &[PathBuf], reported: BTreeMap<String, Vec<Message>>) -> Vec<(PathBuf, Vec<Diagnostic>)> {
|
||||
let real = |p: &Path| dunce::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
|
||||
let mut messages: BTreeMap<PathBuf, Vec<Message>> = BTreeMap::new();
|
||||
for (name, list) in reported {
|
||||
let name = name.split(" (in context of ").next().unwrap_or(&name);
|
||||
messages.entry(real(Path::new(name))).or_default().extend(list);
|
||||
}
|
||||
files
|
||||
.iter()
|
||||
.map(|file| {
|
||||
let list = messages.remove(&real(file)).unwrap_or_default();
|
||||
// Columns only: a file in another encoding than UTF-8 still gives the right lines.
|
||||
let text = if list.is_empty() {
|
||||
None
|
||||
} else {
|
||||
std::fs::read(file).ok().map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
|
||||
};
|
||||
let items = list.into_iter().map(|m| diagnostic(m, text.as_deref())).collect();
|
||||
(file.clone(), items)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn diagnostic(m: Message, text: Option<&str>) -> Diagnostic {
|
||||
Diagnostic {
|
||||
range: line_range(text, m.line),
|
||||
severity: Severity::Warning,
|
||||
message: m.text,
|
||||
source: Some("PHPStan".into()),
|
||||
server: Some(SOURCE.into()),
|
||||
code: m.identifier,
|
||||
unnecessary: false,
|
||||
deprecated: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// PHPStan reports lines only: the line from its first non-blank character to its end (UTF-16).
|
||||
fn line_range(text: Option<&str>, line: u32) -> Range {
|
||||
let text = text.map(|t| t.strip_prefix('\u{feff}').unwrap_or(t));
|
||||
let content = text.and_then(|t| t.split('\n').nth(line as usize)).unwrap_or("").trim_end();
|
||||
let indent = content.len() - content.trim_start().len();
|
||||
let col = |s: &str| u32::try_from(s.encode_utf16().count()).unwrap_or(u32::MAX);
|
||||
Range { start: Pos { line, col: col(&content[..indent]) }, end: Pos { line, col: col(content) } }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const REPORT: &str = r#"{"totals":{"errors":0,"file_errors":3},"files":{
|
||||
"/p/src/A.php":{"errors":2,"messages":[
|
||||
{"message":"Constant UNKNOWN not found.","line":11,"ignorable":true,
|
||||
"tip":"Learn more at https://phpstan.org/user-guide/discovering-symbols","identifier":"constant.notFound"},
|
||||
{"message":"Access to an undefined property.","line":3,"ignorable":true,
|
||||
"tip":"Learn more: <fg=cyan>https://phpstan.org/blog/solving</> for array<int, string>","identifier":"property.notFound"}]},
|
||||
"/p/src/T.php (in context of class App\\A)":{"errors":1,"messages":[
|
||||
{"message":"Syntax error","line":null,"ignorable":false,"identifier":"phpstan.parse"}]}},
|
||||
"errors":[]}"#;
|
||||
|
||||
fn message(line: u32, text: &str, identifier: &str) -> Message {
|
||||
Message { line, text: text.into(), identifier: Some(identifier.into()) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messages_keep_their_line_tip_and_identifier() {
|
||||
let report = parse(REPORT, "Note: Using configuration file /p/phpstan.neon.").unwrap();
|
||||
assert!(report.errors.is_empty());
|
||||
assert_eq!(
|
||||
report.files["/p/src/A.php"],
|
||||
[
|
||||
message(
|
||||
10,
|
||||
"Constant UNKNOWN not found.\nLearn more at https://phpstan.org/user-guide/discovering-symbols",
|
||||
"constant.notFound"
|
||||
),
|
||||
message(
|
||||
2,
|
||||
"Access to an undefined property.\nLearn more: https://phpstan.org/blog/solving for array<int, string>",
|
||||
"property.notFound"
|
||||
),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
report.files["/p/src/T.php (in context of class App\\A)"],
|
||||
[message(0, "Syntax error", "phpstan.parse")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_of_the_run_are_not_file_messages() {
|
||||
let stdout =
|
||||
r#"{"totals":{"errors":1,"file_errors":0},"files":[],"errors":["Invalid entries in excludePaths"]}"#;
|
||||
let report = parse(stdout, "").unwrap();
|
||||
assert!(report.files.is_empty());
|
||||
assert_eq!(report.errors, ["Invalid entries in excludePaths"]);
|
||||
// PHP warnings ahead of the report, or a byte order mark printed by a bootstrap file.
|
||||
let warned = format!("Deprecated: something in /p/x.php on line 3\n{stdout}");
|
||||
assert_eq!(parse(&warned, "").unwrap().errors, ["Invalid entries in excludePaths"]);
|
||||
let marked = format!("\u{feff}{stdout}");
|
||||
assert_eq!(parse(&marked, "").unwrap().errors, ["Invalid entries in excludePaths"]);
|
||||
assert_eq!(parse(&format!("Notice: x{stdout}"), "").unwrap().errors, ["Invalid entries in excludePaths"]);
|
||||
assert!(parse(stdout, "").unwrap().complete);
|
||||
let severe = " Result is incomplete because of severe errors.";
|
||||
assert!(!parse(stdout, severe).unwrap().complete);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_without_a_report_is_a_failure() {
|
||||
let stderr = "Note: Using configuration file /p/phpstan.neon.\n\n Invalid entry in excludePaths:\n Path \"/p/nothere\" is neither a directory\n\n";
|
||||
assert_eq!(
|
||||
parse("", stderr),
|
||||
Err("Invalid entry in excludePaths:\nPath \"/p/nothere\" is neither a directory".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse("Fatal error: Allowed memory size exhausted", ""),
|
||||
Err("Fatal error: Allowed memory size exhausted".into())
|
||||
);
|
||||
assert_eq!(parse("", ""), Err(String::new()));
|
||||
// Excluded files are no failure: nothing to report.
|
||||
assert_eq!(parse("", " [ERROR] No files found to analyse.\n"), Ok(Report::default()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_files_get_their_messages_and_clean_files_none() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(dir.path()).unwrap();
|
||||
let (a, t, clean) = (root.join("A.php"), root.join("T.php"), root.join("Clean.php"));
|
||||
std::fs::write(&a, "<?php\n\n echo $é; \r\n").unwrap();
|
||||
std::fs::write(&t, "<?php\n").unwrap();
|
||||
std::fs::write(&clean, "<?php\n").unwrap();
|
||||
let reported = BTreeMap::from([
|
||||
(a.to_string_lossy().into_owned(), vec![message(2, "Undefined variable", "variable.undefined")]),
|
||||
(format!("{} (in context of class A)", t.display()), vec![message(0, "In a trait", "trait.x")]),
|
||||
("/elsewhere/B.php".to_string(), vec![message(0, "Not checked", "x")]),
|
||||
]);
|
||||
let out = diagnostics(&[a.clone(), t.clone(), clean.clone()], reported);
|
||||
assert_eq!(
|
||||
out.iter().map(|(p, items)| (p.clone(), items.len())).collect::<Vec<_>>(),
|
||||
[(a, 1), (t, 1), (clean, 0)]
|
||||
);
|
||||
let d = &out[0].1[0];
|
||||
// From the first non-blank character to the end of the line, in UTF-16 columns.
|
||||
assert_eq!(d.range, Range { start: Pos { line: 2, col: 4 }, end: Pos { line: 2, col: 12 } });
|
||||
assert_eq!(
|
||||
(d.code.as_deref(), d.source.as_deref(), d.server.as_deref(), d.severity),
|
||||
(Some("variable.undefined"), Some("PHPStan"), Some("phpstan"), Severity::Warning)
|
||||
);
|
||||
assert_eq!(line_range(None, 7), Range { start: Pos { line: 7, col: 0 }, end: Pos { line: 7, col: 0 } });
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn burst_saves_give_one_check_of_all_files() {
|
||||
let mut queue = Queue::default();
|
||||
queue.push([PathBuf::from("/p/A.php")]);
|
||||
let token = queue.cancel.clone();
|
||||
let lifetime = CancellationToken::new();
|
||||
let pending_lifetime = lifetime.clone();
|
||||
let waiting = tokio::spawn(async move { debounce(&token, &pending_lifetime).await });
|
||||
tokio::task::yield_now().await;
|
||||
tokio::time::advance(DELAY - Duration::from_millis(1)).await;
|
||||
assert!(!waiting.is_finished());
|
||||
for i in 0..20 {
|
||||
queue.push([PathBuf::from(format!("/p/{}.php", i % 3))]);
|
||||
}
|
||||
assert!(!waiting.await.unwrap());
|
||||
let current = queue.generation;
|
||||
assert!(debounce(&queue.cancel, &lifetime).await);
|
||||
assert!(queue.current(current));
|
||||
assert_eq!(queue.files.len(), 4);
|
||||
lifetime.cancel();
|
||||
assert!(!debounce(&queue.cancel, &lifetime).await);
|
||||
}
|
||||
|
||||
/// A project with PHPStan whose PHP logs each run, waits `pause` seconds, prints `stderr` and
|
||||
/// reports `report`.
|
||||
#[cfg(unix)]
|
||||
struct Fixture {
|
||||
_dir: tempfile::TempDir,
|
||||
root: PathBuf,
|
||||
events: Arc<Mutex<Vec<IdeEvent>>>,
|
||||
trusted: Arc<AtomicBool>,
|
||||
checker: Arc<Phpstan>,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(dir.path()).unwrap();
|
||||
let root = base.join("project");
|
||||
std::fs::create_dir_all(root.join("vendor/bin")).unwrap();
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
std::fs::write(root.join("vendor/bin/phpstan"), "<?php\n").unwrap();
|
||||
// Named php8.5: the selected PHP runs vendor/bin/phpstan, whatever its name.
|
||||
let php = base.join("php/php8.5");
|
||||
std::fs::create_dir_all(php.parent().unwrap()).unwrap();
|
||||
let script = "#!/bin/sh\nshift\necho \"$XDEBUG_MODE $*\" >> runs.log\n\
|
||||
sleep \"$(cat pause 2>/dev/null || echo 0)\"\ncat stderr >&2 2>/dev/null\ncat report\n";
|
||||
std::fs::write(&php, script).unwrap();
|
||||
std::fs::set_permissions(&php, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
std::fs::create_dir_all(root.join(".openidea")).unwrap();
|
||||
let settings = format!("[languages.php]\ninterpreter = {:?}\n", php.to_string_lossy());
|
||||
std::fs::write(root.join(".openidea/settings.toml"), settings).unwrap();
|
||||
let config = base.join("config");
|
||||
let settings = Arc::new(SettingsStore::load(&config, Some(&root)));
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink_events = events.clone();
|
||||
let sink: SharedSink = Arc::new(move |e| sink_events.lock().push(e));
|
||||
let trusted = Arc::new(AtomicBool::new(true));
|
||||
let checker = Phpstan::new(root.clone(), config, settings, sink, trusted.clone());
|
||||
checker.start(&tokio::runtime::Handle::current());
|
||||
Self { _dir: dir, root, events, trusted, checker }
|
||||
}
|
||||
|
||||
fn file(&self, name: &str) -> PathBuf {
|
||||
let path = self.root.join("src").join(name);
|
||||
std::fs::write(&path, "<?php\necho UNKNOWN;\n").unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn report(&self, path: &Path, identifiers: &[&str]) {
|
||||
let messages: Vec<Value> = identifiers
|
||||
.iter()
|
||||
.map(|id| serde_json::json!({"message": "Bad", "line": 2, "identifier": id, "ignorable": true}))
|
||||
.collect();
|
||||
let files = if messages.is_empty() {
|
||||
serde_json::json!({})
|
||||
} else {
|
||||
serde_json::json!({ path.to_string_lossy(): {"errors": messages.len(), "messages": messages} })
|
||||
};
|
||||
std::fs::write(self.root.join("report"), serde_json::json!({"files": files, "errors": []}).to_string())
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn runs(&self) -> Vec<String> {
|
||||
std::fs::read_to_string(self.root.join("runs.log")).unwrap_or_default().lines().map(str::to_owned).collect()
|
||||
}
|
||||
|
||||
async fn until(&self, what: &str, done: impl Fn(&Self) -> bool) {
|
||||
for _ in 0..200 {
|
||||
if done(self) {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
panic!("timed out waiting for {what}: runs {:?}, events {:?}", self.runs(), self.events.lock());
|
||||
}
|
||||
|
||||
fn diagnostics(&self) -> Vec<(PathBuf, Vec<Option<String>>)> {
|
||||
self.events
|
||||
.lock()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
IdeEvent::Diagnostics { path, source, items } if source == SOURCE => {
|
||||
Some((path.to_path_buf(), items.iter().map(|d| d.code.clone()).collect()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn notifications(&self) -> Vec<String> {
|
||||
let events = self.events.lock();
|
||||
events.iter().filter(|e| matches!(e, IdeEvent::Notify { .. })).map(|e| format!("{e:?}")).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn saves_are_checked_together_and_a_newer_save_cancels_the_check() {
|
||||
let f = Fixture::new();
|
||||
let a = f.file("A.php");
|
||||
f.report(&a, &["constant.notFound"]);
|
||||
for _ in 0..5 {
|
||||
f.checker.files_saved([a.clone(), f.root.join("src/readme.txt")]);
|
||||
}
|
||||
f.until("one check", |f| f.diagnostics().len() == 1).await;
|
||||
assert_eq!(f.runs(), [format!("off analyse --error-format=json --no-progress {}", a.display())]);
|
||||
assert_eq!(f.diagnostics(), [(a.clone(), vec![Some("constant.notFound".to_string())])]);
|
||||
|
||||
// A save during a check kills it; the next check covers both files.
|
||||
std::fs::write(f.root.join("pause"), "30").unwrap();
|
||||
f.checker.files_saved([a.clone()]);
|
||||
f.until("the slow check", |f| f.runs().len() == 2).await;
|
||||
std::fs::write(f.root.join("pause"), "0").unwrap();
|
||||
let b = f.file("B.php");
|
||||
f.report(&a, &[]);
|
||||
f.checker.files_saved([b.clone()]);
|
||||
f.until("the check of both", |f| f.runs().len() == 3 && f.diagnostics().len() == 2).await;
|
||||
assert!(f.runs()[2].ends_with(&format!("{} {}", a.display(), b.display())), "{:?}", f.runs());
|
||||
// A clean result clears A; B never had diagnostics, so nothing is sent for it.
|
||||
assert_eq!(f.diagnostics()[1], (a, vec![]));
|
||||
f.checker.stop();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_failing_run_is_reported_once_and_projects_without_phpstan_stay_quiet() {
|
||||
let f = Fixture::new();
|
||||
let a = f.file("A.php");
|
||||
f.report(&a, &["constant.notFound"]);
|
||||
f.checker.files_saved([a.clone()]);
|
||||
f.until("the first check", |f| f.diagnostics().len() == 1).await;
|
||||
|
||||
std::fs::write(f.root.join("report"), "").unwrap();
|
||||
for run in 2..=3 {
|
||||
f.checker.files_saved([a.clone()]);
|
||||
f.until("the failing check", |f| f.runs().len() == run).await;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
let notes = f.notifications();
|
||||
assert_eq!(notes.len(), 1, "{notes:?}");
|
||||
assert!(notes[0].contains("PHPStan check failed") && notes[0].contains("no report"), "{notes:?}");
|
||||
// The failed check leaves no stale errors.
|
||||
assert_eq!(f.diagnostics(), [(a.clone(), vec![Some("constant.notFound".to_string())]), (a.clone(), vec![])]);
|
||||
|
||||
std::fs::remove_file(f.root.join("vendor/bin/phpstan")).unwrap();
|
||||
let events = f.events.lock().len();
|
||||
f.checker.files_saved([a.clone()]);
|
||||
tokio::time::sleep(DELAY * 2).await;
|
||||
assert_eq!(f.events.lock().len(), events);
|
||||
f.checker.stop();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn files_a_severe_error_left_unchecked_keep_their_diagnostics() {
|
||||
let f = Fixture::new();
|
||||
let (a, b) = (f.file("A.php"), f.file("B.php"));
|
||||
f.report(&a, &["constant.notFound"]);
|
||||
f.checker.files_saved([a.clone()]);
|
||||
f.until("the check of A", |f| f.diagnostics().len() == 1).await;
|
||||
// A syntax error in B: PHPStan reports B only and says the result is incomplete.
|
||||
f.report(&b, &["phpstan.parse"]);
|
||||
std::fs::write(f.root.join("stderr"), "Result is incomplete because of severe errors.").unwrap();
|
||||
f.checker.files_saved([a.clone(), b.clone()]);
|
||||
f.until("the check of both", |f| f.diagnostics().len() == 2).await;
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
assert_eq!(f.diagnostics()[1], (b, vec![Some("phpstan.parse".to_string())]));
|
||||
assert_eq!(f.diagnostics().len(), 2, "A is not cleared");
|
||||
f.checker.stop();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_file_deleted_during_its_check_gets_no_result_and_no_failure() {
|
||||
let f = Fixture::new();
|
||||
let (a, b) = (f.file("A.php"), f.file("B.php"));
|
||||
f.report(&a, &["constant.notFound"]);
|
||||
std::fs::write(f.root.join("pause"), "1").unwrap();
|
||||
f.checker.files_saved([a.clone(), b.clone()]);
|
||||
f.until("the check", |f| f.runs().len() == 1).await;
|
||||
std::fs::remove_file(&a).unwrap();
|
||||
// B is checked again on its own.
|
||||
f.until("the check of B", |f| f.runs().len() == 2).await;
|
||||
assert!(f.runs()[1].ends_with(&format!("--no-progress {}", b.display())), "{:?}", f.runs());
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
assert!(f.events.lock().is_empty(), "{:?}", f.events.lock());
|
||||
f.checker.stop();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn restricted_mode_stops_the_check_in_progress() {
|
||||
let f = Fixture::new();
|
||||
let a = f.file("A.php");
|
||||
f.report(&a, &["constant.notFound"]);
|
||||
std::fs::write(f.root.join("pause"), "30").unwrap();
|
||||
f.checker.files_saved([a.clone()]);
|
||||
f.until("the check", |f| f.runs().len() == 1).await;
|
||||
f.trusted.store(false, Ordering::Release);
|
||||
f.checker.interrupt();
|
||||
tokio::time::sleep(DELAY * 2).await;
|
||||
assert_eq!(f.runs().len(), 1);
|
||||
assert!(f.events.lock().is_empty());
|
||||
f.checker.stop();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn files_no_longer_checked_lose_their_diagnostics() {
|
||||
let f = Fixture::new();
|
||||
let a = f.file("A.php");
|
||||
f.report(&a, &["constant.notFound"]);
|
||||
f.checker.files_saved([a.clone()]);
|
||||
f.until("the check", |f| f.diagnostics().len() == 1).await;
|
||||
// PHPStan removed from the project: saving A no longer checks it and drops its errors.
|
||||
std::fs::remove_file(f.root.join("vendor/bin/phpstan")).unwrap();
|
||||
f.checker.files_saved([a.clone()]);
|
||||
f.until("the clear", |f| f.diagnostics().len() == 2).await;
|
||||
assert_eq!(f.diagnostics()[1], (a, vec![]));
|
||||
assert_eq!(f.runs().len(), 1);
|
||||
f.checker.stop();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn removed_files_lose_their_diagnostics() {
|
||||
let f = Fixture::new();
|
||||
let a = f.file("A.php");
|
||||
f.report(&a, &["constant.notFound"]);
|
||||
f.checker.files_saved([a.clone()]);
|
||||
f.until("the check", |f| f.diagnostics().len() == 1).await;
|
||||
f.checker.files_removed([f.root.join("src/other")].iter());
|
||||
assert_eq!(f.diagnostics().len(), 1);
|
||||
f.checker.files_removed([f.root.join("src")].iter());
|
||||
assert_eq!(f.diagnostics()[1], (a, vec![]));
|
||||
f.checker.stop();
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,7 @@ pub struct Project {
|
||||
/// Native notebooks and owned kernels, scoped to this project incarnation.
|
||||
pub notebooks: Arc<crate::notebook::NotebookService>,
|
||||
pub(crate) kotlin_bridge: Option<Arc<crate::kotlin_bridge::KotlinBridge>>,
|
||||
pub(crate) phpstan: Option<Arc<crate::phpstan::Phpstan>>,
|
||||
pub(crate) watcher: Watcher,
|
||||
/// Watchers of the Project Structure content roots outside the project folder.
|
||||
pub(crate) content_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
|
||||
@@ -292,12 +293,21 @@ impl Host {
|
||||
trusted.clone(),
|
||||
);
|
||||
documents.add_observer(kotlin_bridge.clone());
|
||||
let phpstan = crate::phpstan::Phpstan::new(
|
||||
root.clone(),
|
||||
self.config_dir.clone(),
|
||||
settings.clone(),
|
||||
sink.clone(),
|
||||
trusted.clone(),
|
||||
);
|
||||
documents.add_observer(phpstan.clone());
|
||||
let repos = Arc::new(RwLock::new(Repos::enclosing(&root)?));
|
||||
for repo in repos.read().all() {
|
||||
repo.set_execution_allowed(trusted.load(Ordering::Acquire));
|
||||
}
|
||||
let watcher = {
|
||||
let kotlin_bridge = kotlin_bridge.clone();
|
||||
let phpstan = phpstan.clone();
|
||||
let repos = repos.clone();
|
||||
let settings = settings.clone();
|
||||
let index = index.clone();
|
||||
@@ -321,6 +331,7 @@ impl Host {
|
||||
let (ignored, ignored_gone) = ignored_changes(batch);
|
||||
index.update(&[&changed[..], &ignored].concat(), &[&batch.removed[..], &ignored_gone].concat());
|
||||
kotlin_bridge.files_changed(changed.iter().chain(&batch.removed));
|
||||
phpstan.files_removed(batch.removed.iter());
|
||||
lsp.project_files_changed(batch.created.iter().chain(&batch.removed));
|
||||
lsp.watched_files_changed(&batch.created, &batch.modified, &batch.removed, &batch.excluded_dirs);
|
||||
if composer_changed(&root, changed.iter().chain(&batch.removed)) {
|
||||
@@ -440,6 +451,7 @@ impl Host {
|
||||
&history,
|
||||
&lsp,
|
||||
Some(&kotlin_bridge),
|
||||
Some(&phpstan),
|
||||
)?;
|
||||
Some((r.clone(), w))
|
||||
})
|
||||
@@ -486,6 +498,7 @@ impl Host {
|
||||
agents,
|
||||
notebooks,
|
||||
kotlin_bridge: Some(kotlin_bridge),
|
||||
phpstan: Some(phpstan),
|
||||
watcher,
|
||||
content_watchers: Mutex::new(content_watchers),
|
||||
lsp_watchers: Mutex::new(Vec::new()),
|
||||
@@ -516,6 +529,9 @@ impl Host {
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.start(&self.runtime);
|
||||
}
|
||||
if let Some(phpstan) = &project.phpstan {
|
||||
phpstan.start(&self.runtime);
|
||||
}
|
||||
if let Some(cmd) = post_start {
|
||||
self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone());
|
||||
}
|
||||
@@ -626,6 +642,7 @@ impl Host {
|
||||
agents,
|
||||
notebooks,
|
||||
kotlin_bridge: None,
|
||||
phpstan: None,
|
||||
watcher,
|
||||
content_watchers: Mutex::new(Vec::new()),
|
||||
lsp_watchers: Mutex::new(Vec::new()),
|
||||
@@ -1002,6 +1019,7 @@ impl Project {
|
||||
&self.history,
|
||||
&self.lsp,
|
||||
self.kotlin_bridge.as_ref(),
|
||||
self.phpstan.as_ref(),
|
||||
) {
|
||||
watchers.push((r, w));
|
||||
}
|
||||
@@ -1137,8 +1155,9 @@ fn content_root_watcher(
|
||||
history: &Arc<LocalHistory>,
|
||||
lsp: &Arc<LspManager>,
|
||||
kotlin_bridge: Option<&Arc<crate::kotlin_bridge::KotlinBridge>>,
|
||||
phpstan: Option<&Arc<crate::phpstan::Phpstan>>,
|
||||
) -> Option<Watcher> {
|
||||
let kotlin_bridge = kotlin_bridge.cloned();
|
||||
let (kotlin_bridge, phpstan) = (kotlin_bridge.cloned(), phpstan.cloned());
|
||||
let (index, documents, history, lsp) = (index.clone(), documents.clone(), history.clone(), lsp.clone());
|
||||
let root = dir.to_path_buf();
|
||||
Watcher::start(
|
||||
@@ -1156,6 +1175,9 @@ fn content_root_watcher(
|
||||
if let Some(bridge) = &kotlin_bridge {
|
||||
bridge.files_changed(changed.iter().chain(&batch.removed));
|
||||
}
|
||||
if let Some(phpstan) = &phpstan {
|
||||
phpstan.files_removed(batch.removed.iter());
|
||||
}
|
||||
documents.on_disk_changed(&[&changed[..], &ignored].concat());
|
||||
record_external_changes(&history, &changed, &batch.removed);
|
||||
}),
|
||||
|
||||
@@ -109,6 +109,9 @@ impl crate::Host {
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.schedule();
|
||||
}
|
||||
if let Some(phpstan) = &project.phpstan {
|
||||
phpstan.interrupt();
|
||||
}
|
||||
if !trusted {
|
||||
project.lsp.set_execution_allowed(false);
|
||||
}
|
||||
|
||||
@@ -162,7 +162,8 @@ pub fn client_capabilities() -> Value {
|
||||
"callHierarchy": { "dynamicRegistration": false },
|
||||
"typeHierarchy": { "dynamicRegistration": false }
|
||||
},
|
||||
"experimental": {}
|
||||
// rust-analyzer: report when the project has loaded (`quiescent`).
|
||||
"experimental": { "serverStatusNotification": true }
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -234,6 +234,11 @@ impl Handler for ClientHandler {
|
||||
let Some(mgr) = self.mgr.upgrade() else { return };
|
||||
match method {
|
||||
"$/progress" => self.progress(&mgr, params),
|
||||
"experimental/serverStatus" => {
|
||||
if let Some(quiescent) = params.get("quiescent").and_then(Value::as_bool) {
|
||||
self.server.set_loading(mgr.sink(), !quiescent);
|
||||
}
|
||||
}
|
||||
"textDocument/publishDiagnostics" => self.publish_diagnostics(&mgr, params),
|
||||
"window/showMessage" => self.show_message(&mgr, ¶ms),
|
||||
"window/logMessage" => {
|
||||
|
||||
@@ -4,7 +4,7 @@ use ide_core::CancellationToken;
|
||||
use ide_core::tool_path::search_path;
|
||||
use ide_jsonrpc::{Handler, Peer};
|
||||
use ide_language::LanguageServerConfig;
|
||||
use std::ffi::OsString;
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -100,7 +100,8 @@ impl Launcher for ProcessLauncher {
|
||||
// A plugin's server: the approved program, unchanged since.
|
||||
Some(pin) => Some(pin.verify().map_err(LaunchError::Failed)?.to_path_buf()),
|
||||
None => ide_core::tool_path::find_command(&config.command, &search, root)
|
||||
.and_then(|b| resolve_rustup_proxy(&config.command, b)),
|
||||
.and_then(|b| resolve_rustup_proxy(&config.command, b))
|
||||
.and_then(|b| reject_dead_shim(b, &search, root)),
|
||||
};
|
||||
let mut env: Vec<(String, String)> = vec![("PATH".into(), search.to_string_lossy().into_owned())];
|
||||
env.extend(config.env.iter().filter(|(k, _)| *k != "PATH").map(|(k, v)| (k.clone(), v.clone())));
|
||||
@@ -136,6 +137,60 @@ fn server_search_path(config: &LanguageServerConfig) -> OsString {
|
||||
config.env.get("PATH").map(OsString::from).unwrap_or_else(search_path)
|
||||
}
|
||||
|
||||
/// What a version manager's shim says when the active version has no such tool.
|
||||
const DEAD_SHIM_MESSAGES: &[&str] =
|
||||
&["no version is set for", "no preset version", "command not found", "exists in these", "is not installed"];
|
||||
|
||||
/// `~/.local/share/mise/shims/ruby-lsp` exists for every version of the runtime that ever
|
||||
/// had the tool, but exits with an error when the version active in `root` lacks it. A
|
||||
/// server that dies like that on every start is missing (an install hint), not crashing.
|
||||
/// Anything but a clear "not installed" answer from the shim counts as working.
|
||||
fn reject_dead_shim(binary: PathBuf, search: &OsStr, root: &Path) -> Option<PathBuf> {
|
||||
if !binary.parent().is_some_and(|d| d.file_name().is_some_and(|n| n == "shims")) {
|
||||
return Some(binary);
|
||||
}
|
||||
let Ok(mut child) = ide_core::process::prepare(&mut std::process::Command::new(&binary))
|
||||
.arg("--version")
|
||||
.current_dir(root)
|
||||
.env("PATH", search)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
else {
|
||||
return Some(binary);
|
||||
};
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
let status = loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => break status,
|
||||
Ok(None) if std::time::Instant::now() < deadline => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(20))
|
||||
}
|
||||
// Slow or unwaitable: let the real start decide.
|
||||
_ => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Some(binary);
|
||||
}
|
||||
}
|
||||
};
|
||||
if status.success() {
|
||||
return Some(binary);
|
||||
}
|
||||
let mut stderr = String::new();
|
||||
if let Some(mut pipe) = child.stderr.take() {
|
||||
let _ = std::io::Read::read_to_string(&mut pipe, &mut stderr);
|
||||
}
|
||||
let stderr = stderr.to_lowercase();
|
||||
if DEAD_SHIM_MESSAGES.iter().any(|m| stderr.contains(m)) {
|
||||
tracing::info!("{} is a version-manager shim without the tool: {}", binary.display(), stderr.trim());
|
||||
None
|
||||
} else {
|
||||
Some(binary)
|
||||
}
|
||||
}
|
||||
|
||||
/// `~/.cargo/bin/<tool>` is usually a rustup proxy that fails when the active
|
||||
/// toolchain lacks the component (common for rust-analyzer). In that case use
|
||||
/// the newest installed toolchain that ships the binary; with none, the tool is
|
||||
@@ -219,6 +274,34 @@ mod tests {
|
||||
std::fs::remove_dir_all(&tmp).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn shims_without_the_tool_are_missing() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let shims = tmp.path().join("mise/shims");
|
||||
std::fs::create_dir_all(&shims).unwrap();
|
||||
let script = |name: &str, body: &str| {
|
||||
let p = shims.join(name);
|
||||
std::fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap();
|
||||
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
p
|
||||
};
|
||||
let search = OsString::from("/usr/bin:/bin");
|
||||
let dead = script("ruby-lsp", "echo 'mise ERROR No version is set for shim: ruby-lsp' >&2; exit 1");
|
||||
assert_eq!(reject_dead_shim(dead, &search, tmp.path()), None);
|
||||
let works = script("good", "echo 1.0");
|
||||
assert_eq!(reject_dead_shim(works.clone(), &search, tmp.path()), Some(works));
|
||||
// A tool with no `--version` that fails for its own reasons still starts.
|
||||
let odd = script("odd", "echo 'usage: odd' >&2; exit 2");
|
||||
assert_eq!(reject_dead_shim(odd.clone(), &search, tmp.path()), Some(odd));
|
||||
// Only shims are probed.
|
||||
let plain = tmp.path().join("ruby-lsp");
|
||||
std::fs::write(&plain, "#!/bin/sh\necho 'No version is set for' >&2; exit 1\n").unwrap();
|
||||
std::fs::set_permissions(&plain, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert_eq!(reject_dead_shim(plain.clone(), &search, tmp.path()), Some(plain));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marked_root_needs_a_marker() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -18,6 +18,7 @@ mod inspect;
|
||||
pub mod jdtls;
|
||||
mod launch;
|
||||
mod library_source;
|
||||
mod loading;
|
||||
mod pull;
|
||||
mod scope;
|
||||
mod server;
|
||||
@@ -216,6 +217,15 @@ fn runtime_handle() -> tokio::runtime::Handle {
|
||||
})
|
||||
}
|
||||
|
||||
fn goto_is_empty(r: &Option<lsp::GotoDefinitionResponse>) -> bool {
|
||||
match r {
|
||||
None => true,
|
||||
Some(lsp::GotoDefinitionResponse::Scalar(_)) => false,
|
||||
Some(lsp::GotoDefinitionResponse::Array(v)) => v.is_empty(),
|
||||
Some(lsp::GotoDefinitionResponse::Link(v)) => v.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
fn rpc_err(server: &str, method: &str, e: RpcError) -> IdeError {
|
||||
match e {
|
||||
RpcError::Cancelled => IdeError::cancelled(),
|
||||
@@ -1163,8 +1173,13 @@ impl LspManager {
|
||||
let mut out = Outcome::new();
|
||||
for t in targets.iter().filter(|t| snap.asks(t, pos)) {
|
||||
let r: IdeResult<Option<lsp::Hover>> =
|
||||
self.call(t, "textDocument/hover", snap.pos_params(t, pos), Some(&cancel), DEFAULT_TIMEOUT).await;
|
||||
loading::while_loading(&t.server, Some(&cancel), loading::LOADING_WAIT, Option::is_none, || {
|
||||
self.call(t, "textDocument/hover", snap.pos_params(t, pos), Some(&cancel), DEFAULT_TIMEOUT)
|
||||
})
|
||||
.await;
|
||||
match r {
|
||||
// Still loading: no documentation yet, not an error.
|
||||
Err(e) if e.kind == ErrorKind::TimedOut && t.server.is_loading() => out.value = Some(None),
|
||||
Ok(Some(h)) => {
|
||||
if let Some(h) = convert::hover(&self.conv(t.enc()), &snap.path, h) {
|
||||
return Ok(Some(h));
|
||||
@@ -1254,16 +1269,17 @@ impl LspManager {
|
||||
) -> IdeResult<Vec<Location>> {
|
||||
let mut out = Outcome::new();
|
||||
for t in targets.iter().filter(|t| snap.asks(t, pos)) {
|
||||
match self
|
||||
.call::<_, Option<lsp::GotoDefinitionResponse>>(
|
||||
let r = loading::while_loading(&t.server, cancel, loading::LOADING_WAIT, goto_is_empty, || {
|
||||
self.call::<_, Option<lsp::GotoDefinitionResponse>>(
|
||||
t,
|
||||
method,
|
||||
snap.pos_params(t, pos),
|
||||
cancel,
|
||||
DEFAULT_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
{
|
||||
})
|
||||
.await;
|
||||
match r {
|
||||
Ok(Some(r)) => {
|
||||
let locs = convert::goto_response(&self.conv(t.enc()), r);
|
||||
let locs = library_sources(snap, pos, locs).await;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Requests answered while a server is still loading the project.
|
||||
//!
|
||||
//! rust-analyzer answers navigation with nothing until it has read the project's
|
||||
//! files, misses macro-generated items until build scripts and proc macros are
|
||||
//! built, and withdraws answers (`ContentModified`) when loading changes its
|
||||
//! data. Such an answer is not final: the request runs again as loading
|
||||
//! progresses, so ⌘B right after opening a project lands once the server can
|
||||
//! answer instead of reporting "not found". Only servers that report loading
|
||||
//! through `experimental/serverStatus` wait; for others nothing changes.
|
||||
|
||||
use crate::server::Server;
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use std::future::Future;
|
||||
use std::time::Duration;
|
||||
|
||||
/// How long an explicit request waits for a server that is still loading.
|
||||
pub(crate) const LOADING_WAIT: Duration = Duration::from_secs(30);
|
||||
/// Retry interval while it loads, for phases that end without a notification.
|
||||
const POLL: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Runs `query` on `server`. While the server is loading, an empty answer or a
|
||||
/// withdrawn one (`ContentModified`; not a cancellation of `cancel`) runs again
|
||||
/// whenever a loading phase ends, until it answers, loading ends, or `wait`
|
||||
/// passes; then a still-loading server yields a `TimedOut` error saying so.
|
||||
pub(crate) async fn while_loading<R, F, Fut>(
|
||||
server: &Server,
|
||||
cancel: Option<&CancellationToken>,
|
||||
wait: Duration,
|
||||
is_empty: impl Fn(&R) -> bool,
|
||||
query: F,
|
||||
) -> IdeResult<R>
|
||||
where
|
||||
F: Fn() -> Fut,
|
||||
Fut: Future<Output = IdeResult<R>>,
|
||||
{
|
||||
let cancelled = || cancel.is_some_and(CancellationToken::is_cancelled);
|
||||
let deadline = tokio::time::Instant::now() + wait;
|
||||
loop {
|
||||
let phase = server.phase_notified();
|
||||
tokio::pin!(phase);
|
||||
phase.as_mut().enable();
|
||||
let asked_while_loading = server.is_loading();
|
||||
let result = query().await;
|
||||
let retry = match &result {
|
||||
Ok(r) => is_empty(r),
|
||||
Err(e) => e.kind == ErrorKind::Cancelled && !cancelled(),
|
||||
};
|
||||
if !retry || cancelled() || server.running().is_none() {
|
||||
return result;
|
||||
}
|
||||
if !server.is_loading() {
|
||||
// Loading ended while the server answered from its old state: ask once more.
|
||||
if asked_while_loading {
|
||||
continue;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
let now = tokio::time::Instant::now();
|
||||
if now >= deadline {
|
||||
return Err(IdeError::new(ErrorKind::TimedOut, format!("{} is still loading the project", server.name)));
|
||||
}
|
||||
let until = deadline.min(now + POLL);
|
||||
let stop = async {
|
||||
match cancel {
|
||||
Some(c) => c.cancelled().await,
|
||||
None => std::future::pending().await,
|
||||
}
|
||||
};
|
||||
tokio::select! {
|
||||
() = phase => {}
|
||||
() = tokio::time::sleep_until(until) => {}
|
||||
() = stop => return Err(IdeError::cancelled()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,11 @@ pub struct Server {
|
||||
pub published_notify: Notify,
|
||||
/// Woken when the last `$/progress` task ends.
|
||||
idle_notify: Notify,
|
||||
/// `experimental/serverStatus` (rust-analyzer): `None` until the server sends
|
||||
/// one, then whether it is still loading the project.
|
||||
loading: Mutex<Option<bool>>,
|
||||
/// Woken whenever a `$/progress` task ends or the server's loading state changes.
|
||||
phase_notify: Notify,
|
||||
transient: Mutex<HashMap<PathBuf, Transient>>,
|
||||
pub java_build_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
pub java_autobuild_suspended: std::sync::atomic::AtomicBool,
|
||||
@@ -144,6 +149,8 @@ impl Server {
|
||||
published: Mutex::new(HashMap::new()),
|
||||
published_notify: Notify::new(),
|
||||
idle_notify: Notify::new(),
|
||||
loading: Mutex::new(None),
|
||||
phase_notify: Notify::new(),
|
||||
transient: Mutex::new(HashMap::new()),
|
||||
java_build_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
java_autobuild_suspended: std::sync::atomic::AtomicBool::new(false),
|
||||
@@ -389,22 +396,48 @@ impl Server {
|
||||
if self.running().is_none() {
|
||||
return;
|
||||
}
|
||||
let busy = !self.progress.lock().is_empty();
|
||||
let busy = self.is_busy();
|
||||
self.set_status(sink, if busy { ServerStatus::Busy } else { ServerStatus::Ready }, None);
|
||||
}
|
||||
|
||||
/// Whether the server reports work in progress (indexing, analysis).
|
||||
pub fn is_busy(&self) -> bool {
|
||||
!self.progress.lock().is_empty()
|
||||
!self.progress.lock().is_empty() || *self.loading.lock() == Some(true)
|
||||
}
|
||||
|
||||
/// Whether the server says it is still loading the project, so an empty
|
||||
/// answer may not be final. Only `serverStatus` tells: `$/progress` also
|
||||
/// covers background work (indexing, builds) during which answers are final.
|
||||
pub fn is_loading(&self) -> bool {
|
||||
*self.loading.lock() == Some(true)
|
||||
}
|
||||
|
||||
/// Records `experimental/serverStatus`: `quiescent` false while the project loads.
|
||||
pub fn set_loading(&self, sink: &SharedSink, loading: bool) {
|
||||
if self.loading.lock().replace(loading) == Some(loading) {
|
||||
return;
|
||||
}
|
||||
self.refresh_busy_status(sink);
|
||||
self.phase_ended();
|
||||
}
|
||||
|
||||
/// Called after a `$/progress` task ended.
|
||||
pub fn progress_ended(&self) {
|
||||
self.phase_ended();
|
||||
}
|
||||
|
||||
fn phase_ended(&self) {
|
||||
self.phase_notify.notify_waiters();
|
||||
if !self.is_busy() {
|
||||
self.idle_notify.notify_waiters();
|
||||
}
|
||||
}
|
||||
|
||||
/// Woken when the next loading phase ends.
|
||||
pub fn phase_notified(&self) -> tokio::sync::futures::Notified<'_> {
|
||||
self.phase_notify.notified()
|
||||
}
|
||||
|
||||
/// Waits until no `$/progress` task is active; false on timeout or cancellation.
|
||||
pub async fn wait_idle(&self, timeout: Duration, cancel: &CancellationToken) -> bool {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
@@ -499,6 +532,7 @@ impl Server {
|
||||
}
|
||||
|
||||
pub fn take_progress(&self) -> Vec<(TaskId, String)> {
|
||||
*self.loading.lock() = None;
|
||||
self.progress.lock().drain().map(|(_, p)| (p.task, p.title)).collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -1009,6 +1009,78 @@ async fn progress_tasks_and_busy_status() {
|
||||
assert!(tasks[2].3);
|
||||
}
|
||||
|
||||
/// While a server says it is loading the project (rust-analyzer: `serverStatus`
|
||||
/// not quiescent) an empty or withdrawn answer is asked again once loading moves
|
||||
/// on. `$/progress` alone (background indexing, `cargo check`) doesn't wait.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn navigation_waits_for_a_server_that_is_still_loading() {
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Answer {
|
||||
Empty,
|
||||
ContentModified,
|
||||
}
|
||||
for answer in [Answer::Empty, Answer::ContentModified] {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
let (doc, path) = h.open("a.mk", "fn foo() {}\nfoo();\n");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("mock");
|
||||
let peer = s.peer();
|
||||
peer.notify("experimental/serverStatus", json!({ "health": "ok", "quiescent": false })).unwrap();
|
||||
h.wait_status("mock", ServerStatus::Busy).await;
|
||||
if let Answer::ContentModified = answer {
|
||||
s.errors.lock().insert("textDocument/definition".into(), (-32801, "content modified".into()));
|
||||
}
|
||||
let mgr = h.mgr.clone();
|
||||
let goto = tokio::spawn(async move { mgr.goto(doc, Pos::new(1, 1), GotoKind::Definition).await });
|
||||
eventually(|| !s.requested("textDocument/definition").is_empty()).await;
|
||||
assert!(!goto.is_finished(), "{answer:?}: an answer while loading is not final");
|
||||
|
||||
s.errors.lock().clear();
|
||||
let r = json!({"start":{"line":0,"character":3},"end":{"line":0,"character":6}});
|
||||
s.respond("textDocument/definition", json!({ "uri": uri_of(&path), "range": r }));
|
||||
peer.notify("experimental/serverStatus", json!({ "health": "ok", "quiescent": true })).unwrap();
|
||||
let defs = tokio::time::timeout(Duration::from_secs(10), goto).await.unwrap().unwrap().unwrap();
|
||||
assert_eq!(defs, vec![ide_core::Location::file(path.clone(), rng(0, 3, 0, 6))], "{answer:?}");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
}
|
||||
|
||||
// Hover documentation the same way.
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
let (doc, _) = h.open("a.mk", "x");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("mock");
|
||||
s.peer().notify("experimental/serverStatus", json!({ "health": "ok", "quiescent": false })).unwrap();
|
||||
h.wait_status("mock", ServerStatus::Busy).await;
|
||||
let mgr = h.mgr.clone();
|
||||
let hover = tokio::spawn(async move { mgr.hover(doc, Pos::new(0, 0), Default::default()).await });
|
||||
eventually(|| !s.requested("textDocument/hover").is_empty()).await;
|
||||
s.respond("textDocument/hover", json!({ "contents": "docs" }));
|
||||
s.peer().notify("experimental/serverStatus", json!({ "health": "ok", "quiescent": true })).unwrap();
|
||||
let hover = tokio::time::timeout(Duration::from_secs(10), hover).await.unwrap().unwrap().unwrap();
|
||||
assert!(hover.is_some());
|
||||
|
||||
// "Nothing here" is the answer at once: rust-analyzer loaded with `cargo check`
|
||||
// running, and a server without `serverStatus` indexing in the background.
|
||||
for quiescent in [Some(true), None] {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
let (doc, _) = h.open("a.mk", "x");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("mock");
|
||||
let peer = s.peer();
|
||||
if let Some(q) = quiescent {
|
||||
peer.notify("experimental/serverStatus", json!({ "health": "ok", "quiescent": q })).unwrap();
|
||||
}
|
||||
peer.notify("$/progress", json!({ "token": "bg", "value": { "kind": "begin", "title": "Indexing" } })).unwrap();
|
||||
h.wait_status("mock", ServerStatus::Busy).await;
|
||||
let defs = tokio::time::timeout(Duration::from_secs(2), h.mgr.goto(doc, Pos::new(0, 0), GotoKind::Definition))
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("{quiescent:?}: no wait for background work"))
|
||||
.unwrap();
|
||||
assert!(defs.is_empty());
|
||||
assert_eq!(s.requested("textDocument/definition").len(), 1, "{quiescent:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn progress_titled_with_an_executable_path_shows_the_program_name() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
@@ -3333,7 +3405,8 @@ async fn interactive_capability_is_scoped_to_gopls() {
|
||||
let (doc, _) = h.open("probe.mk", "x\n");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("mock");
|
||||
assert_eq!(s.requested("initialize")[0]["capabilities"]["experimental"], json!({}));
|
||||
let experimental = &s.requested("initialize")[0]["capabilities"]["experimental"];
|
||||
assert!(experimental.get("interactiveResolve").is_none(), "{experimental}");
|
||||
let form = h
|
||||
.mgr
|
||||
.resolve_command(
|
||||
|
||||
@@ -1059,6 +1059,10 @@ fn ruby_config(
|
||||
c
|
||||
}
|
||||
|
||||
/// Loads the test files of the directories (or files) named in `ARGV`, default `test`, the way `rake test`
|
||||
/// does; options such as `-v` stay in `ARGV` for Minitest.
|
||||
const MINITEST_RUNNER: &str = r#"paths, opts = ARGV.partition { |a| !a.start_with?("-") }; ARGV.replace(opts); (paths.empty? ? ["test"] : paths).flat_map { |d| File.file?(d) ? [d] : Dir[File.join(d, "**", "{*_test,test_*}.rb")] }.sort.each { |f| require File.expand_path(f) }"#;
|
||||
|
||||
fn uses_rspec(root: &Path) -> bool {
|
||||
root.join("spec").is_dir()
|
||||
|| root.join(".rspec").is_file()
|
||||
@@ -1101,6 +1105,17 @@ fn detect_ruby(root: &Path, out: &mut Vec<RunConfig>) {
|
||||
}
|
||||
if uses_rspec(root) {
|
||||
out.push(ruby_config("rspec".into(), "rspec".into(), "rspec", ruby_tool(root, "rspec"), &[]).test());
|
||||
} else if root.join("test").is_dir() && !out.iter().any(|c| c.id == "rake:test") {
|
||||
// Minitest / Test::Unit without a `test` Rake task: load every test file under `test/`.
|
||||
let mut args = Vec::new();
|
||||
for dir in ["lib", "test"] {
|
||||
if root.join(dir).is_dir() {
|
||||
args.push(format!("-I{dir}"));
|
||||
}
|
||||
}
|
||||
args.extend(["-e".to_owned(), MINITEST_RUNNER.to_owned(), "test".to_owned()]);
|
||||
let extra: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
out.push(ruby_config("minitest".into(), "minitest".into(), "ruby", ruby_tool(root, "ruby"), &extra).test());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ pub mod java_cmdline;
|
||||
pub mod junit;
|
||||
mod process;
|
||||
pub mod python_path;
|
||||
pub mod ruby_preload;
|
||||
pub mod ruby_reporter;
|
||||
pub mod tomcat;
|
||||
mod utf8;
|
||||
pub mod which;
|
||||
|
||||
@@ -80,6 +80,8 @@ pub struct StartOptions {
|
||||
pub encoding: Option<String>,
|
||||
/// Project content and source roots added to Python launch environments.
|
||||
pub python_paths: Vec<PathBuf>,
|
||||
/// Where the Ruby test reporter is installed (see [`crate::ruby_reporter`]); Ruby test runs load it.
|
||||
pub ruby_reporter: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -355,6 +357,10 @@ impl ProcessManager {
|
||||
if matches!(config.kind.as_str(), "python" | "pytest" | "django-test") {
|
||||
crate::python_path::extend(&mut env.vars, &options.python_paths, !env.clear)?;
|
||||
}
|
||||
if let Some(dir) = options.ruby_reporter.as_deref().filter(|_| crate::ruby_reporter::applies(config)) {
|
||||
crate::ruby_preload::add(&mut env.vars, dir, crate::ruby_reporter::FEATURE, !env.clear);
|
||||
env.vars.insert("OPENIDEA_TEST_REPORT".into(), "1".into());
|
||||
}
|
||||
let on_output: OutputFn = Arc::from(on_output);
|
||||
let on_output = match extras.output_file.as_deref() {
|
||||
Some(file) => tee_output(&resolve_cwd(root, Some(Path::new(file))), on_output)?,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Loads a Ruby file into a launched process without touching the project: the directory goes on
|
||||
//! `RUBYLIB` and `RUBYOPT` gets the bare feature name (`-ropenidea_hook`).
|
||||
//!
|
||||
//! `RUBYOPT` is split at spaces with no quoting, so an absolute path in it (`-r/…/Application
|
||||
//! Support/…/hook.rb`, where the IDE keeps its data) makes Ruby fail with `invalid switch in RUBYOPT`.
|
||||
//! `RUBYLIB` is a path list and takes spaces.
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Makes `feature` (a file `<dir>/<feature>.rb`) load first in every Ruby the process starts.
|
||||
/// `inherit`: the process also sees the IDE's own environment, whose `RUBYLIB` / `RUBYOPT` stay.
|
||||
pub fn add(env: &mut BTreeMap<String, String>, dir: &Path, feature: &str, inherit: bool) {
|
||||
let mut inherited = if inherit { crate::system_env() } else { BTreeMap::new() };
|
||||
let mut existing = |name: &str| env.get(name).cloned().or_else(|| inherited.remove(name)).filter(|v| !v.is_empty());
|
||||
|
||||
let mut dirs: Vec<PathBuf> = vec![dir.to_path_buf()];
|
||||
if let Some(lib) = existing("RUBYLIB") {
|
||||
for d in std::env::split_paths(&lib) {
|
||||
if !dirs.contains(&d) {
|
||||
dirs.push(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
let rubyopt = existing("RUBYOPT");
|
||||
if let Ok(joined) = std::env::join_paths(dirs) {
|
||||
env.insert("RUBYLIB".into(), joined.to_string_lossy().into_owned());
|
||||
}
|
||||
let option = format!("-r{feature}");
|
||||
let value = match rubyopt {
|
||||
Some(e) if e.split_whitespace().any(|o| o == option) => e,
|
||||
Some(e) => format!("{e} {option}"),
|
||||
None => option,
|
||||
};
|
||||
env.insert("RUBYOPT".into(), value);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_directory_with_spaces_goes_on_rubylib_not_into_rubyopt() {
|
||||
let dir = Path::new("/Users/me/Library/Application Support/ide/ruby");
|
||||
let mut env = BTreeMap::new();
|
||||
add(&mut env, dir, "openidea_hook", false);
|
||||
assert_eq!(env["RUBYLIB"], dir.to_string_lossy());
|
||||
assert_eq!(env["RUBYOPT"], "-ropenidea_hook");
|
||||
assert!(!env["RUBYOPT"].contains(' '));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_existing_settings_and_adds_once() {
|
||||
// Path lists use the platform's separator (`;` on Windows).
|
||||
let list = |dirs: &[&str]| std::env::join_paths(dirs).unwrap().to_string_lossy().into_owned();
|
||||
let mut env = BTreeMap::from([("RUBYLIB".into(), list(&["/a", "/b"])), ("RUBYOPT".into(), "-w".into())]);
|
||||
add(&mut env, Path::new("/hook"), "h", false);
|
||||
add(&mut env, Path::new("/hook"), "h", false);
|
||||
assert_eq!(env["RUBYLIB"], list(&["/hook", "/a", "/b"]));
|
||||
assert_eq!(env["RUBYOPT"], "-w -rh");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! A Minitest reporter the IDE loads into test runs (see `ruby_reporter/`): it prints one record per
|
||||
//! test on stdout, which the IDE strips from the console and turns into the test tree. Tests then
|
||||
//! report their real source file and line, and the output no longer needs `-v` to be parsed.
|
||||
use crate::RunConfig;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
/// The Ruby file `RUBYOPT` loads first (`<dir>/openidea_test_hook.rb`).
|
||||
pub const FEATURE: &str = "openidea_test_hook";
|
||||
|
||||
const FILES: [(&str, &str); 2] = [
|
||||
("openidea_test_hook.rb", include_str!("ruby_reporter/openidea_test_hook.rb")),
|
||||
("openidea/minitest_reporter.rb", include_str!("ruby_reporter/minitest_reporter.rb")),
|
||||
];
|
||||
|
||||
/// Whether a run takes the reporter: Minitest-style test runs through `ruby` or `rake`, with their output
|
||||
/// in the console (a terminal would show the records).
|
||||
pub fn applies(config: &RunConfig) -> bool {
|
||||
config.is_test && matches!(config.kind.as_str(), "ruby" | "rake") && !config.extras.emulate_terminal
|
||||
}
|
||||
|
||||
/// Writes the reporter files under `dir` (unchanged files stay).
|
||||
pub fn install(dir: &Path) -> io::Result<()> {
|
||||
for (name, text) in FILES {
|
||||
let path = dir.join(name);
|
||||
if std::fs::read_to_string(&path).is_ok_and(|current| current == text) {
|
||||
continue;
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(&path, text)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn installs_the_files_once_and_only_for_ruby_test_runs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("ruby reporter");
|
||||
install(&dir).unwrap();
|
||||
install(&dir).unwrap();
|
||||
for (name, text) in FILES {
|
||||
assert_eq!(std::fs::read_to_string(dir.join(name)).unwrap(), text);
|
||||
}
|
||||
assert!(FILES[0].1.contains("OPENIDEA_TEST_REPORT") && FILES[1].1.contains("@@OPENIDEA@@"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
# Written by OpenIDEA for test runs; registered by `openidea_test_hook.rb` once Minitest is loaded.
|
||||
# Nothing in the project is changed.
|
||||
#
|
||||
# One record per line on stdout, which the IDE strips from the console again:
|
||||
# @@OPENIDEA@@{"t":"start","c":"MyTest","n":"test_x","f":"/abs/test.rb","l":12}
|
||||
# @@OPENIDEA@@{"t":"result","c":"MyTest","n":"test_x","s":"passed","d":0.001,"f":...,"l":...,"m":"failure text"}
|
||||
# @@OPENIDEA@@{"t":"summary","runs":7,"failures":1,"errors":1,"skips":1,"d":0.4}
|
||||
require "json"
|
||||
|
||||
module Minitest
|
||||
class OpenIdeaReporter < AbstractReporter
|
||||
MARK = "@@OPENIDEA@@"
|
||||
|
||||
def initialize(io)
|
||||
super()
|
||||
@io = io
|
||||
@cwd = Dir.pwd
|
||||
@lock = Mutex.new
|
||||
@runs = @failures = @errors = @skips = 0
|
||||
@started = nil
|
||||
end
|
||||
|
||||
def start
|
||||
@started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
end
|
||||
|
||||
def prerecord(klass, name)
|
||||
emit(t: "start", c: klass.to_s, n: name.to_s, **where(klass, name))
|
||||
end
|
||||
|
||||
def record(result)
|
||||
@runs += 1
|
||||
status = result.skipped? ? "skipped" : result.passed? ? "passed" : "failed"
|
||||
@skips += 1 if status == "skipped"
|
||||
if status == "failed"
|
||||
result.failures.any? { |f| f.is_a?(UnexpectedError) } ? @errors += 1 : @failures += 1
|
||||
end
|
||||
loc = result.source_location
|
||||
emit(t: "result", c: result.klass.to_s, n: result.name.to_s, s: status, d: result.time,
|
||||
f: loc && absolute(loc[0]), l: loc && loc[1], m: result.failures.map(&:message).join("\n"))
|
||||
end
|
||||
|
||||
def report
|
||||
d = @started ? Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started : nil
|
||||
emit(t: "summary", runs: @runs, failures: @failures, errors: @errors, skips: @skips, d: d)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Where the test is defined, known before it runs (so the tree can show it while running).
|
||||
def where(klass, name)
|
||||
f, l = klass.instance_method(name).source_location
|
||||
{ f: absolute(f), l: l }
|
||||
rescue StandardError
|
||||
{}
|
||||
end
|
||||
|
||||
# Test files are often loaded by a path relative to the working directory.
|
||||
def absolute(file)
|
||||
File.expand_path(file, @cwd)
|
||||
end
|
||||
|
||||
def emit(record)
|
||||
line = "#{MARK}#{JSON.generate(record)}\n"
|
||||
@lock.synchronize { @io.write(line) }
|
||||
rescue StandardError
|
||||
nil # reporting must never fail a test run
|
||||
end
|
||||
end
|
||||
|
||||
def self.plugin_openidea_init(options)
|
||||
return unless ENV["OPENIDEA_TEST_REPORT"]
|
||||
|
||||
io = options[:io] || $stdout
|
||||
io.sync = true if io.respond_to?(:sync=)
|
||||
reporter << OpenIdeaReporter.new(io)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,32 @@
|
||||
# Written by OpenIDEA for test runs. Loaded first in every Ruby the run starts
|
||||
# (RUBYLIB=<this directory>, RUBYOPT=-ropenidea_test_hook).
|
||||
#
|
||||
# Registers the test reporter once a test framework is loaded. Minitest 5 would find a plugin on
|
||||
# its own, Minitest 6 does not load plugins any more, so both are registered the same way.
|
||||
if ENV["OPENIDEA_TEST_REPORT"]
|
||||
module OpenIdeaTestHook
|
||||
@registered = false
|
||||
|
||||
def self.register
|
||||
return if @registered
|
||||
return unless defined?(::Minitest) && ::Minitest.respond_to?(:extensions) && defined?(::Minitest::AbstractReporter)
|
||||
|
||||
@registered = true
|
||||
require "openidea/minitest_reporter"
|
||||
::Minitest.extensions << "openidea" unless ::Minitest.extensions.include?("openidea")
|
||||
rescue StandardError, LoadError
|
||||
nil # reporting must never break a test run
|
||||
end
|
||||
|
||||
# `require "minitest/autorun"` loads Minitest through require_relative, so look after every require.
|
||||
module Watch
|
||||
def require(path)
|
||||
result = super
|
||||
OpenIdeaTestHook.register
|
||||
result
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Kernel.prepend(OpenIdeaTestHook::Watch)
|
||||
end
|
||||
@@ -71,6 +71,62 @@ pub fn context_xml(doc_base: &Path, reload: bool) -> String {
|
||||
format!("<Context docBase=\"{}\" reloadable=\"{reload}\" />\n", escape(&doc_base.to_string_lossy()))
|
||||
}
|
||||
|
||||
/// The web app's own `META-INF/context.xml`, from an exploded directory or a WAR.
|
||||
fn app_context_xml(doc_base: &Path) -> Option<String> {
|
||||
if doc_base.is_dir() {
|
||||
return fs::read_to_string(doc_base.join("META-INF/context.xml")).ok();
|
||||
}
|
||||
let mut zip = zip::ZipArchive::new(fs::File::open(doc_base).ok()?).ok()?;
|
||||
let mut text = String::new();
|
||||
zip.by_name("META-INF/context.xml").ok()?.read_to_string(&mut text).ok()?;
|
||||
Some(text)
|
||||
}
|
||||
|
||||
/// The context descriptor for `conf/Catalina/localhost`. Tomcat ignores the app's
|
||||
/// `META-INF/context.xml` once a descriptor exists there, so its content (`<Resource>`,
|
||||
/// `<Environment>`, `<Valve>`…) is carried over, with `docBase` and `reloadable` set by the IDE.
|
||||
pub fn deployed_context_xml(doc_base: &Path, reload: bool) -> String {
|
||||
let Some(app) = app_context_xml(doc_base) else { return context_xml(doc_base, reload) };
|
||||
merge_context_xml(&app, doc_base, reload).unwrap_or_else(|e| {
|
||||
tracing::warn!("ignoring the web app's META-INF/context.xml: {e}");
|
||||
context_xml(doc_base, reload)
|
||||
})
|
||||
}
|
||||
|
||||
fn merge_context_xml(app: &str, doc_base: &Path, reload: bool) -> IdeResult<String> {
|
||||
let mut reader = Reader::from_str(app);
|
||||
let mut writer = Writer::new(Vec::new());
|
||||
let mut root = false;
|
||||
loop {
|
||||
let event = reader.read_event().map_err(invalid)?;
|
||||
let event = match event {
|
||||
Event::Eof => break,
|
||||
Event::Start(ref tag) | Event::Empty(ref tag) if !root => {
|
||||
if tag.name().as_ref() != "Context" {
|
||||
return Err(invalid("the root element is not <Context>"));
|
||||
}
|
||||
root = true;
|
||||
let mut new = BytesStart::new("Context");
|
||||
for a in tag.attributes() {
|
||||
let a = a.map_err(invalid)?;
|
||||
if !matches!(a.key.as_ref(), "docBase" | "reloadable" | "path") {
|
||||
new.push_attribute(a);
|
||||
}
|
||||
}
|
||||
new.push_attribute(("docBase", doc_base.to_string_lossy().as_ref()));
|
||||
new.push_attribute(("reloadable", if reload { "true" } else { "false" }));
|
||||
if matches!(event, Event::Empty(_)) { Event::Empty(new) } else { Event::Start(new) }
|
||||
}
|
||||
e => e,
|
||||
};
|
||||
writer.write_event(event).map_err(invalid)?;
|
||||
}
|
||||
if !root {
|
||||
return Err(invalid("no <Context> element"));
|
||||
}
|
||||
String::from_utf8(writer.into_inner()).map_err(invalid)
|
||||
}
|
||||
|
||||
pub fn patch_server_xml(xml: &str, http: u16, shutdown: u16, secret: &str) -> IdeResult<String> {
|
||||
let mut reader = Reader::from_str(xml);
|
||||
let mut writer = Writer::new(Vec::new());
|
||||
@@ -442,7 +498,7 @@ pub fn prepare(root: &Path, cache: &Path, config: &mut RunConfig) -> IdeResult<(
|
||||
let server = patch_server_xml(&fs::read_to_string(home.join("conf/server.xml"))?, port, shutdown, &secret)?;
|
||||
fs::write(base.join("conf/server.xml"), server)?;
|
||||
let debug = config.extras.params.get("tomcatDebug").is_some_and(|v| v == "true");
|
||||
fs::write(contexts.join(format!("{context}.xml")), context_xml(&doc, !debug))?;
|
||||
fs::write(contexts.join(format!("{context}.xml")), deployed_context_xml(&doc, !debug))?;
|
||||
config.env.insert("CATALINA_HOME".into(), home.to_string_lossy().into_owned());
|
||||
config.env.insert("CATALINA_BASE".into(), base.to_string_lossy().into_owned());
|
||||
if let Some(jre) = config.extras.params.get("jre").filter(|s| !s.trim().is_empty()) {
|
||||
@@ -501,6 +557,48 @@ mod tests {
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn keeps_the_apps_own_context_descriptor() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let app = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Context path=\"/old\" docBase=\"old\" reloadable=\"false\" antiResourceLocking=\"true\">\n <!-- JNDI -->\n <Resource name=\"jdbc/x\" auth=\"Container\" type=\"javax.sql.DataSource\" url=\"jdbc:h2:mem:a;b=&c\"/>\n <Environment name=\"mode\" value=\"dev\" type=\"java.lang.String\"/>\n</Context>\n";
|
||||
let dir = tmp.path().join("web app");
|
||||
fs::create_dir_all(dir.join("META-INF")).unwrap();
|
||||
fs::write(dir.join("META-INF/context.xml"), app).unwrap();
|
||||
let war = tmp.path().join("app.war");
|
||||
archive(&war, &[("META-INF/context.xml", app), ("index.jsp", "")]);
|
||||
for doc in [&dir, &war] {
|
||||
let xml = deployed_context_xml(doc, true);
|
||||
let doc_base = escape(&doc.to_string_lossy());
|
||||
assert!(
|
||||
xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Context antiResourceLocking=\"true\""),
|
||||
"{xml}"
|
||||
);
|
||||
assert!(xml.contains(&format!("docBase=\"{doc_base}\" reloadable=\"true\">")), "{xml}");
|
||||
assert!(!xml.contains("/old") && !xml.contains("\"old\"") && !xml.contains("\"false\""), "{xml}");
|
||||
assert!(xml.contains("<!-- JNDI -->"));
|
||||
assert!(xml.contains("<Resource name=\"jdbc/x\" auth=\"Container\" type=\"javax.sql.DataSource\" url=\"jdbc:h2:mem:a;b=&c\"/>"));
|
||||
assert!(xml.contains("<Environment name=\"mode\""));
|
||||
assert!(xml.trim_end().ends_with("</Context>"));
|
||||
}
|
||||
|
||||
fs::write(dir.join("META-INF/context.xml"), "<Context/>").unwrap();
|
||||
let doc_base = escape(&dir.to_string_lossy());
|
||||
assert_eq!(
|
||||
deployed_context_xml(&dir, false),
|
||||
format!("<Context docBase=\"{doc_base}\" reloadable=\"false\"/>")
|
||||
);
|
||||
// Unreadable or not a context: the IDE's own descriptor, as before.
|
||||
for broken in ["<Context><Resource></Context>", "<Server/>", ""] {
|
||||
fs::write(dir.join("META-INF/context.xml"), broken).unwrap();
|
||||
assert_eq!(deployed_context_xml(&dir, false), context_xml(&dir, false), "{broken}");
|
||||
}
|
||||
let plain = tmp.path().join("plain");
|
||||
fs::create_dir_all(&plain).unwrap();
|
||||
assert_eq!(deployed_context_xml(&plain, true), context_xml(&plain, true));
|
||||
let bare = tmp.path().join("bare.war");
|
||||
archive(&bare, &[("index.jsp", "")]);
|
||||
assert_eq!(deployed_context_xml(&bare, true), context_xml(&bare, true));
|
||||
}
|
||||
fn config() -> RunConfig {
|
||||
RunConfig {
|
||||
id: "local:demo".into(),
|
||||
|
||||
@@ -820,6 +820,25 @@ task default: :build
|
||||
assert_eq!(find(&c, "rake:build").extras.folder.as_deref(), Some("Rake"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruby_minitest_projects_get_a_test_runner() {
|
||||
let dir = project(&[("Gemfile", "source 'https://rubygems.org'\n"), ("lib/a.rb", ""), ("test/a_test.rb", "")]);
|
||||
let c = detect(dir.path());
|
||||
let m = find(&c, "minitest");
|
||||
assert!(m.is_test);
|
||||
assert_eq!(m.kind, "ruby");
|
||||
assert_eq!(
|
||||
(m.command.as_str(), &m.args[..4]),
|
||||
("bundle", &["exec", "ruby", "-Ilib", "-Itest"].map(String::from)[..])
|
||||
);
|
||||
assert_eq!(m.args.last().map(String::as_str), Some("test"));
|
||||
// A `test` Rake task or RSpec already runs the tests.
|
||||
let dir = project(&[("Rakefile", "task :test\n"), ("test/a_test.rb", "")]);
|
||||
assert!(!ids(&detect(dir.path())).contains(&"minitest"));
|
||||
let dir = project(&[("Gemfile", "gem 'rspec'\n"), ("test/a_test.rb", "")]);
|
||||
assert!(!ids(&detect(dir.path())).contains(&"minitest"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruby_without_bundler_runs_bare_tools() {
|
||||
let dir = project(&[("Rakefile", "task :test\n")]);
|
||||
|
||||
@@ -479,6 +479,36 @@ fn python_scripts_import_sibling_packages_and_preserve_env_file_paths() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruby_test_runs_load_the_reporter_and_other_runs_do_not() {
|
||||
let reporter = std::env::temp_dir().join("ruby reporter");
|
||||
let run = |kind: &str, is_test: bool| {
|
||||
let h = harness();
|
||||
let mut config =
|
||||
cfg("ruby-env", "/bin/sh", &["-c", r#"echo "lib=$RUBYLIB opt=$RUBYOPT report=$OPENIDEA_TEST_REPORT""#]);
|
||||
config.kind = kind.into();
|
||||
config.is_test = is_test;
|
||||
let options = StartOptions { ruby_reporter: Some(reporter.clone()), ..Default::default() };
|
||||
let chunks: Chunks = Arc::default();
|
||||
let sink = chunks.clone();
|
||||
let info =
|
||||
h.pm.start(h.root.path(), &config, &options, h.sink.clone(), Box::new(move |c| sink.lock().push(c)))
|
||||
.unwrap();
|
||||
assert_eq!(wait_exit(&h, info.id, Duration::from_secs(10)), Some(0));
|
||||
text(&chunks, stdout)
|
||||
};
|
||||
for kind in ["ruby", "rake"] {
|
||||
let out = run(kind, true);
|
||||
// The directory has a space: it is on RUBYLIB and only the bare feature name is in RUBYOPT.
|
||||
assert!(out.contains(&format!("lib={}", reporter.display())), "{out}");
|
||||
assert!(out.contains("-ropenidea_test_hook") && !out.contains(&format!("-r{}", reporter.display())), "{out}");
|
||||
assert!(out.contains("report=1"), "{out}");
|
||||
}
|
||||
for (kind, is_test) in [("ruby", false), ("rspec", true), ("pytest", true)] {
|
||||
assert!(run(kind, is_test).contains("report=\n"), "{kind} {is_test}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revoking_one_project_stops_only_its_runs() {
|
||||
let h = harness();
|
||||
|
||||
@@ -691,6 +691,13 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
|
||||
path_dirs.push(dir);
|
||||
}
|
||||
|
||||
// Gems install to the Ruby's bindir, which is not always on `PATH` (Homebrew).
|
||||
if name == "ruby-lsp"
|
||||
&& let Some(ruby) = ruby_interpreter(root, s)
|
||||
{
|
||||
path_dirs.extend(crate::ruby::gem_bin_dirs(&ruby, server_root));
|
||||
}
|
||||
|
||||
if !path_dirs.is_empty() {
|
||||
let dirs: Vec<&Path> = path_dirs.iter().map(PathBuf::as_path).collect();
|
||||
cfg.env.insert("PATH".into(), path_with(&dirs));
|
||||
@@ -1301,3 +1308,29 @@ mod maven_linkage_tests {
|
||||
assert!(serde_json::to_value(cfg.initialization_options).unwrap().get("projectConfigurations").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod ruby_lsp_tests {
|
||||
use super::*;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
#[test]
|
||||
fn ruby_lsp_is_found_in_the_rubys_gem_bindir_when_that_is_off_path() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let bindir = tmp.path().join("gems/bin");
|
||||
std::fs::create_dir_all(&bindir).unwrap();
|
||||
// Homebrew's Ruby: `Gem.bindir` is not on `PATH`.
|
||||
let ruby = tmp.path().join("ruby");
|
||||
std::fs::write(&ruby, format!("#!/bin/sh\necho '{}'\necho '/nonexistent/user/bin'\n", bindir.display()))
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&ruby, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let mut s = Settings::default();
|
||||
s.languages.ruby.interpreter = ruby.to_string_lossy().into_owned();
|
||||
let mut cfg = custom_server("ruby-lsp", &LanguageServerSettings::default());
|
||||
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
|
||||
let path = cfg.env.get("PATH").expect("PATH set");
|
||||
let dirs: Vec<PathBuf> = std::env::split_paths(path).collect();
|
||||
assert!(dirs.contains(&bindir), "{dirs:?}");
|
||||
assert!(!dirs.iter().any(|d| d.starts_with("/nonexistent")), "{dirs:?}");
|
||||
}
|
||||
}
|
||||
+103
-2
@@ -1,12 +1,14 @@
|
||||
//! 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);
|
||||
//! the Intelephense storage that goes with it; and the stub sets and include paths Intelephense indexes.
|
||||
//! the Intelephense storage that goes with it; the stub sets and include paths Intelephense indexes;
|
||||
//! and the PHPStan run that checks saved files.
|
||||
|
||||
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;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// A PHP version and where it comes from.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
|
||||
@@ -139,6 +141,33 @@ pub fn include_paths(root: &Path, s: &Settings) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `vendor/bin/phpstan analyse` of `files` with a JSON report and the configuration file of
|
||||
/// Settings › PHP, run from `root` with Xdebug off: in debug mode it would try to reach a
|
||||
/// debugger on every run. The selected PHP runs the script itself (and is first on `PATH`, as for
|
||||
/// PHPUnit), so a `php8.1` is not replaced by the `php` its `#!/usr/bin/env php` finds, and
|
||||
/// Windows needs no `phpstan.bat`, whose `cmd` limits the command line. `None` when the project
|
||||
/// has no PHPStan.
|
||||
pub fn phpstan_command(root: &Path, s: &Settings, files: &[PathBuf]) -> Option<Command> {
|
||||
let bin = root.join("vendor/bin/phpstan");
|
||||
if !bin.is_file() {
|
||||
return None;
|
||||
}
|
||||
let php = setting_path(root, &s.languages.php.interpreter)
|
||||
.or_else(|| crate::exec::which("php"))
|
||||
.unwrap_or_else(|| PathBuf::from("php"));
|
||||
let mut cmd = Command::new(php);
|
||||
cmd.current_dir(root).arg(bin).args(["analyse", "--error-format=json", "--no-progress"]);
|
||||
if let Some(config) = setting_path(root, &s.languages.php.phpstan_config) {
|
||||
cmd.arg("--configuration").arg(config);
|
||||
}
|
||||
cmd.args(files);
|
||||
cmd.env("XDEBUG_MODE", "off");
|
||||
if let Some(dir) = crate::apply::php_dir(root, s) {
|
||||
cmd.env("PATH", crate::exec::path_with(&[&dir]));
|
||||
}
|
||||
Some(cmd)
|
||||
}
|
||||
|
||||
type Version = (u32, u32, u32);
|
||||
|
||||
impl PhpLanguageLevel {
|
||||
@@ -350,6 +379,78 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn phpstan(root: &Path, s: &Settings) -> Option<Command> {
|
||||
let cmd = phpstan_command(root, s, &[root.join("src").join("A.php"), root.join("src").join("B.php")])?;
|
||||
assert_eq!(cmd.get_current_dir(), Some(root));
|
||||
Some(cmd)
|
||||
}
|
||||
|
||||
fn text(s: &std::ffi::OsStr) -> String {
|
||||
s.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn env(cmd: &Command) -> Vec<(String, Option<String>)> {
|
||||
cmd.get_envs().map(|(k, v)| (text(k), v.map(text))).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phpstan_checks_the_files_on_the_selected_php_without_xdebug() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let mut s = Settings::default();
|
||||
assert!(phpstan(root, &s).is_none(), "no vendor/bin/phpstan, no run");
|
||||
|
||||
let bin = root.join("vendor/bin/phpstan");
|
||||
std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
|
||||
std::fs::write(&bin, "").unwrap();
|
||||
let cmd = phpstan(root, &s).unwrap();
|
||||
// The PHP on PATH unless one is selected.
|
||||
assert_eq!(Path::new(cmd.get_program()).file_stem(), Some("php".as_ref()));
|
||||
let file = |name: &str| root.join("src").join(name).to_string_lossy().into_owned();
|
||||
assert_eq!(
|
||||
cmd.get_args().map(text).collect::<Vec<_>>(),
|
||||
[
|
||||
&*bin.to_string_lossy(),
|
||||
"analyse",
|
||||
"--error-format=json",
|
||||
"--no-progress",
|
||||
&file("A.php"),
|
||||
&file("B.php")
|
||||
]
|
||||
);
|
||||
assert_eq!(env(&cmd), [("XDEBUG_MODE".to_string(), Some("off".to_string()))]);
|
||||
|
||||
// A selected PHP runs PHPStan whatever its name, and is first on PATH for what PHPStan starts.
|
||||
let php = root.join("php85/bin").join(crate::exe("php8.5"));
|
||||
s.languages.php.interpreter = php.to_string_lossy().into_owned();
|
||||
let cmd = phpstan(root, &s).unwrap();
|
||||
assert_eq!(cmd.get_program(), php.as_os_str());
|
||||
let env = env(&cmd);
|
||||
let path = env.iter().find(|(k, _)| k == "PATH").and_then(|(_, v)| v.clone()).expect("PATH");
|
||||
assert_eq!(std::env::split_paths(&path).next(), Some(root.join("php85/bin")));
|
||||
assert!(env.contains(&("XDEBUG_MODE".to_string(), Some("off".to_string()))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phpstan_uses_the_configuration_file_of_the_settings() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let bin = root.join("vendor/bin/phpstan");
|
||||
std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
|
||||
std::fs::write(&bin, "").unwrap();
|
||||
let args = |config: &str| {
|
||||
let mut s = Settings::default();
|
||||
s.languages.php.phpstan_config = config.into();
|
||||
phpstan(root, &s).unwrap().get_args().map(text).collect::<Vec<_>>()
|
||||
};
|
||||
let resolved = root.join("build/phpstan.ci.neon").to_string_lossy().into_owned();
|
||||
assert_eq!(args(" build/phpstan.ci.neon ")[4..6], ["--configuration".to_string(), resolved]);
|
||||
let absolute = if cfg!(windows) { "C:/etc/phpstan.neon" } else { "/etc/phpstan.neon" };
|
||||
assert_eq!(args(absolute)[4..6], ["--configuration", absolute]);
|
||||
// Empty: PHPStan finds phpstan.neon, phpstan.neon.dist or phpstan.dist.neon itself.
|
||||
assert!(!args(" ").iter().any(|a| a.starts_with("--configuration")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_level_names_its_source() {
|
||||
let found = |version: &str, source| Some(PhpLanguageLevel { version: version.into(), source });
|
||||
|
||||
@@ -140,6 +140,14 @@ pub fn bin_dir_of(ruby: &Path) -> Option<PathBuf> {
|
||||
ruby.parent().map(Path::to_path_buf)
|
||||
}
|
||||
|
||||
/// Where `ruby` installs gem executables (`Gem.bindir`, the `--user-install` one) that exist, asked
|
||||
/// of the Ruby that version managers select in `root`: Homebrew's keeps its bindir off `PATH`.
|
||||
pub fn gem_bin_dirs(ruby: &Path, root: &Path) -> Vec<PathBuf> {
|
||||
query_in(ruby, &["-e", ide_core::tool_path::RUBY_GEM_BIN_SCRIPT], Some(root), &[], QUERY_TIMEOUT)
|
||||
.map(|out| ide_core::tool_path::gem_bin_dirs(&out))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
type NamedRoots = Vec<(String, PathBuf)>;
|
||||
|
||||
/// Standard-library / site directories (`ruby/3.3.0`) and the gems Bundler resolves
|
||||
|
||||
@@ -154,7 +154,11 @@ fn managed_bin_dir(root: &Path, spec: &ToolSpec, platform: Platform) -> Option<P
|
||||
Method::PipVenv { .. } => {
|
||||
Some(root.join(spec.name).join("venv").join(if platform.os == Os::Windows { "Scripts" } else { "bin" }))
|
||||
}
|
||||
Method::Bundled { .. } | Method::Rustup { .. } | Method::PipUser { .. } | Method::PipTarget { .. } => None,
|
||||
Method::Bundled { .. }
|
||||
| Method::Rustup { .. }
|
||||
| Method::PipUser { .. }
|
||||
| Method::PipTarget { .. }
|
||||
| Method::GemActive { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,6 +433,9 @@ pub struct Tools {
|
||||
/// Executables found outside the search path (app bundles), by command.
|
||||
found: BTreeMap<String, PathBuf>,
|
||||
version_timeout: Duration,
|
||||
/// The project the tools are used for: tools that install into "the active runtime" (gems) run here,
|
||||
/// so a version manager picks the project's Ruby.
|
||||
work_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Tools {
|
||||
@@ -450,6 +457,7 @@ impl Tools {
|
||||
custom: BTreeMap::new(),
|
||||
found: BTreeMap::new(),
|
||||
version_timeout: Duration::from_secs(4),
|
||||
work_dir: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,10 +473,25 @@ impl Tools {
|
||||
self
|
||||
}
|
||||
|
||||
/// Runs per-project installs (see [`registry::Method::GemActive`]) in `dir`.
|
||||
pub fn with_work_dir(mut self, dir: Option<PathBuf>) -> Self {
|
||||
self.work_dir = dir.filter(|d| d.is_dir());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
/// [`Tools::command`] in the project directory.
|
||||
fn command_in_project(&self, program: impl AsRef<std::ffi::OsStr>) -> Command {
|
||||
let mut cmd = self.command(program);
|
||||
if let Some(dir) = &self.work_dir {
|
||||
cmd.current_dir(dir);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
fn which(&self, cmd: &str) -> Option<PathBuf> {
|
||||
ide_core::tool_path::find_command(cmd, &self.system_path, &self.root)
|
||||
}
|
||||
@@ -614,7 +637,9 @@ impl Tools {
|
||||
"npm or pnpm is required: install Node.js (https://nodejs.org)",
|
||||
),
|
||||
Method::Go { .. } => need(self.which("go").is_some(), "Go is required (https://go.dev/dl)"),
|
||||
Method::Gem { .. } => need(self.which("gem").is_some(), "Ruby is required (https://www.ruby-lang.org)"),
|
||||
Method::Gem { .. } | Method::GemActive { .. } => {
|
||||
need(self.which("gem").is_some(), "Ruby is required (https://www.ruby-lang.org)")
|
||||
}
|
||||
Method::DotnetTool { .. } => {
|
||||
need(self.which("dotnet").is_some(), ".NET SDK is required (https://dotnet.microsoft.com/download)")
|
||||
}
|
||||
@@ -772,6 +797,22 @@ impl Tools {
|
||||
_ => (ToolStatus::Missing, None, None),
|
||||
}
|
||||
}
|
||||
Method::GemActive { gem } => {
|
||||
// Installed for the Ruby active in the project, not merely a shim of some other version's.
|
||||
let Some(gem_cmd) = self.which("gem") else { return (ToolStatus::Missing, None, None) };
|
||||
let mut c = self.command_in_project(&gem_cmd);
|
||||
c.args(["list", "--exact", gem]);
|
||||
match run_quiet(c, Duration::from_secs(10)) {
|
||||
Some((true, out)) if out.contains(&format!("{gem} (")) => {
|
||||
// Homebrew's Ruby keeps its gem bindir off PATH: ask the Ruby where it is.
|
||||
match self.gem_executable(main) {
|
||||
Some(path) => (ToolStatus::OnPath, Some(path), parse_version(&out)),
|
||||
None => (ToolStatus::Missing, None, None),
|
||||
}
|
||||
}
|
||||
_ => (ToolStatus::Missing, None, None),
|
||||
}
|
||||
}
|
||||
_ => match self.which(main) {
|
||||
Some(path) => {
|
||||
let version = self.query_version(spec, &path);
|
||||
@@ -868,6 +909,7 @@ impl Tools {
|
||||
Method::Npm { packages } => self.install_npm(packages, cancel, emit)?,
|
||||
Method::Go { module } => self.install_go(spec, module, cancel, emit)?,
|
||||
Method::Gem { gem } => self.install_gem(spec, gem, cancel, emit)?,
|
||||
Method::GemActive { gem } => self.install_gem_active(gem, cancel, emit)?,
|
||||
Method::DotnetTool { package } => self.install_dotnet_tool(spec, package, cancel, emit)?,
|
||||
Method::PipVenv { package } => self.install_venv(spec, package, cancel, emit)?,
|
||||
Method::PipUser { package, .. } => self.install_pip_user(package, cancel, emit)?,
|
||||
@@ -953,6 +995,41 @@ impl Tools {
|
||||
self.write_manifest(spec.name, &Manifest { version, source: Some(module.to_string()) })
|
||||
}
|
||||
|
||||
/// Where the project's Ruby installs gem executables (`Gem.bindir`, `Gem.user_dir`'s `bin`).
|
||||
fn gem_bin_dirs(&self) -> Vec<PathBuf> {
|
||||
let Some(ruby) = self.which("ruby") else { return Vec::new() };
|
||||
let mut c = self.command_in_project(ruby);
|
||||
c.args(["-e", ide_core::tool_path::RUBY_GEM_BIN_SCRIPT]);
|
||||
match run_quiet(c, Duration::from_secs(10)) {
|
||||
Some((true, out)) => ide_core::tool_path::gem_bin_dirs(&out),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// An installed gem's executable: on the search path, else in one of the Ruby's gem bin directories.
|
||||
fn gem_executable(&self, cmd: &str) -> Option<PathBuf> {
|
||||
self.which(cmd).or_else(|| self.gem_bin_dirs().iter().find_map(|d| self.exe_in(d, cmd)))
|
||||
}
|
||||
|
||||
/// `gem install` into the active Ruby, then makes version-manager shims see the new executable.
|
||||
fn install_gem_active(&self, gem: &str, cancel: &CancellationToken, emit: Emit) -> IdeResult<()> {
|
||||
let mut c = self.command_in_project(self.which("gem").expect("checked"));
|
||||
c.args(["install", "--no-document", gem]);
|
||||
run_streamed(c, cancel, emit)?;
|
||||
// mise / asdf / rbenv list executables in shims written at install time; a gem installed
|
||||
// afterwards has none until they are regenerated. Best effort: the tool may not be installed.
|
||||
for (manager, args) in
|
||||
[("mise", &["reshim"][..]), ("asdf", &["reshim", "ruby"][..]), ("rbenv", &["rehash"][..])]
|
||||
{
|
||||
if let Some(path) = self.which(manager) {
|
||||
let mut c = self.command_in_project(path);
|
||||
c.args(args);
|
||||
let _ = run_quiet(c, Duration::from_secs(30));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_gem(&self, spec: &ToolSpec, gem: &str, cancel: &CancellationToken, emit: Emit) -> IdeResult<()> {
|
||||
let dir = self.root.join(spec.name);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
@@ -1310,6 +1387,18 @@ impl Tools {
|
||||
run_streamed(c, &cancel, &quiet)?;
|
||||
}
|
||||
}
|
||||
Method::GemActive { gem } => {
|
||||
if let Some(gem_cmd) = self.which("gem") {
|
||||
let mut c = self.command_in_project(&gem_cmd);
|
||||
c.args(["uninstall", "--all", "--executables", "--ignore-dependencies", gem]);
|
||||
run_streamed(c, &cancel, &quiet)?;
|
||||
}
|
||||
// A copy an older version installed under the tools directory.
|
||||
let dir = self.root.join(spec.name);
|
||||
if dir.exists() {
|
||||
std::fs::remove_dir_all(&dir)?;
|
||||
}
|
||||
}
|
||||
Method::Go { .. }
|
||||
| Method::Gem { .. }
|
||||
| Method::DotnetTool { .. }
|
||||
|
||||
@@ -49,6 +49,11 @@ pub enum Method {
|
||||
PipTarget { package: &'static str, module: &'static str },
|
||||
/// `gem install` into `<tools>/<name>/gems`, launched through `GEM_HOME` wrappers in `bin/`.
|
||||
Gem { gem: &'static str },
|
||||
/// `gem install <gem>` into the active Ruby (the project's, installed from a project window),
|
||||
/// launched through that Ruby's own executables. For tools that re-launch themselves under
|
||||
/// Bundler (ruby-lsp), which breaks in a gem home under a path with spaces
|
||||
/// (`~/Library/Application Support`: Bundler `exec`s a lone path string, and Ruby splits it).
|
||||
GemActive { gem: &'static str },
|
||||
/// `dotnet tool install --tool-path <tools>/<name>/tool <package>`, launched
|
||||
/// through `DOTNET_ROOT` wrappers in `bin/`.
|
||||
DotnetTool { package: &'static str },
|
||||
@@ -64,6 +69,7 @@ impl Method {
|
||||
Method::Npm { .. } => "npm",
|
||||
Method::Go { .. } => "go install",
|
||||
Method::Gem { .. } => "gem",
|
||||
Method::GemActive { .. } => "gem (project Ruby)",
|
||||
Method::DotnetTool { .. } => "dotnet tool",
|
||||
Method::PipVenv { .. } => "pip (virtualenv)",
|
||||
Method::PipUser { .. } => "pip --user",
|
||||
@@ -940,7 +946,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
display: "Ruby LSP (Shopify)",
|
||||
kind: LanguageServer,
|
||||
commands: &["ruby-lsp"],
|
||||
method: Method::Gem { gem: "ruby-lsp" },
|
||||
method: Method::GemActive { gem: "ruby-lsp" },
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://shopify.github.io/ruby-lsp",
|
||||
|
||||
@@ -590,6 +590,51 @@ printf '#!/bin/sh\necho "$GEM_HOME"\n' > "$bin/solargraph"; /bin/chmod +x "$bin/
|
||||
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), env.root.join("solargraph/gems").to_string_lossy());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ruby_lsp_installs_into_the_project_ruby_not_a_private_gem_home() {
|
||||
let env = Env::new();
|
||||
let project = env._tmp.path().join("project");
|
||||
fs::create_dir_all(&project).unwrap();
|
||||
let marker = env._tmp.path().join("installed");
|
||||
// Homebrew's Ruby: the gem bindir is not on the search path; only the Ruby knows it.
|
||||
let bindir = env._tmp.path().join("gems/bin");
|
||||
fs::create_dir_all(&bindir).unwrap();
|
||||
env.script(&env.sys, "ruby", &format!("echo '{}'", bindir.display()));
|
||||
// `gem list --exact ruby-lsp` knows the gem once `gem install ruby-lsp` ran, in the cwd it was given.
|
||||
env.script(
|
||||
&env.sys,
|
||||
"gem",
|
||||
&format!(
|
||||
r#"case "$1" in
|
||||
install) echo "$PWD $@" > '{m}'; printf '#!/bin/sh\n' > '{b}/ruby-lsp'; /bin/chmod +x '{b}/ruby-lsp' ;;
|
||||
list) [ -f '{m}' ] && echo "ruby-lsp (0.26.11)" ;;
|
||||
uninstall) /bin/rm -f '{m}' ;;
|
||||
esac"#,
|
||||
m = marker.display(),
|
||||
b = bindir.display()
|
||||
),
|
||||
);
|
||||
let tools = env.tools().with_work_dir(Some(project.clone()));
|
||||
assert_eq!(find(&tools.list(&langs()), "ruby-lsp").status, ToolStatus::Missing);
|
||||
let info = tools.install("ruby-lsp", &langs(), &CancellationToken::new(), &|_| {}).unwrap();
|
||||
assert_eq!(info.status, ToolStatus::OnPath);
|
||||
assert_eq!(info.version.as_deref(), Some("0.26.11"));
|
||||
// Found in the Ruby's own bindir, not reported as the `gem` command.
|
||||
assert_eq!(info.path.as_deref(), Some(bindir.join("ruby-lsp").as_path()));
|
||||
// Run in the project (so a version manager picks its Ruby), into the Ruby's own gem home.
|
||||
let log = fs::read_to_string(&marker).unwrap();
|
||||
assert!(log.starts_with(&*project.canonicalize().unwrap().to_string_lossy()), "{log}");
|
||||
assert!(log.contains("install --no-document ruby-lsp") && !log.contains("--install-dir"), "{log}");
|
||||
// No private gem home, no wrapper directory on the search path.
|
||||
assert!(!env.root.join("ruby-lsp").exists());
|
||||
// Uninstalling removes the gem, and an older managed copy.
|
||||
fs::create_dir_all(env.root.join("ruby-lsp/gems")).unwrap();
|
||||
let info = tools.uninstall("ruby-lsp", &langs()).unwrap();
|
||||
assert_eq!(info.status, ToolStatus::Missing);
|
||||
assert!(!env.root.join("ruby-lsp").exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn dotnet_tool_install_into_tool_path() {
|
||||
|
||||
@@ -537,9 +537,15 @@ impl Documents {
|
||||
pub fn edit(&self, doc: DocId, base_version: u32, changes: ChangeSet) -> IdeResult<u32> {
|
||||
self.with_doc(doc, |st| {
|
||||
if st.read_only {
|
||||
tracing::warn!("edit rejected: {} is read-only", st.path.display());
|
||||
return Err(read_only_error(&st.path));
|
||||
}
|
||||
let (applied, edits) = st.doc.apply_detailed(base_version, &changes)?;
|
||||
let (applied, edits) = st.doc.apply_detailed(base_version, &changes).inspect_err(|e| {
|
||||
// A stale version is routine (the UI rebases); anything else makes the editor drop the edit.
|
||||
if e.kind != ErrorKind::OutOfSync {
|
||||
tracing::warn!("edit of {} rejected: {e}", st.path.display());
|
||||
}
|
||||
})?;
|
||||
self.set_dirty(doc, st, true);
|
||||
self.notify_changed(doc, st, &applied, &edits);
|
||||
Ok(st.doc.version())
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Window grouping is independent of a window's project or workspace.
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Platform adapters supply the system's tab preference and active project state.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -117,6 +118,43 @@ impl WindowGroups {
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry of the window session reopened at the next start (`window-groups.json`): a
|
||||
/// window group, or a single project window (one project).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SavedWindows {
|
||||
pub projects: Vec<PathBuf>,
|
||||
/// Index into `projects` of the group's selected tab.
|
||||
pub selected: usize,
|
||||
}
|
||||
|
||||
/// The project windows open now, to reopen at the next start: every group with its selected
|
||||
/// tab, then each window outside a group, in the order of `windows`. `project_of` gives a
|
||||
/// window's project (`None` for the welcome screen or LightEdit). `None` while a grouped
|
||||
/// window has no project yet (a restored member still opening): keep the previous session.
|
||||
pub fn session(
|
||||
groups: &[WindowGroup],
|
||||
windows: &[String],
|
||||
project_of: impl Fn(&str) -> Option<PathBuf>,
|
||||
) -> Option<Vec<SavedWindows>> {
|
||||
let mut saved = Vec::new();
|
||||
for group in groups {
|
||||
let projects: Option<Vec<PathBuf>> = group.members.iter().map(|label| project_of(label)).collect();
|
||||
let selected = group.members.iter().position(|l| l == &group.selected).unwrap_or(0);
|
||||
saved.push(SavedWindows { projects: projects?, selected });
|
||||
}
|
||||
for label in windows {
|
||||
if groups.iter().any(|g| g.members.contains(label)) {
|
||||
continue;
|
||||
}
|
||||
if let Some(project) = project_of(label)
|
||||
&& !saved.iter().any(|s| s.projects.contains(&project))
|
||||
{
|
||||
saved.push(SavedWindows { projects: vec![project], selected: 0 });
|
||||
}
|
||||
}
|
||||
Some(saved)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -186,4 +224,28 @@ mod tests {
|
||||
groups.reorder("a", members(&["d", "c", "b", "a"])).unwrap();
|
||||
assert_eq!(groups.group("a").unwrap().members[0], "d");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_session_keeps_groups_and_every_ungrouped_project_window() {
|
||||
let groups = vec![WindowGroup { members: members(&["project-1", "project-2"]), selected: "project-2".into() }];
|
||||
let windows = members(&["main", "project-1", "project-2", "project-3", "project-4"]);
|
||||
let project_of = |label: &str| match label {
|
||||
"main" => Some(PathBuf::from("/a")),
|
||||
"project-1" => Some(PathBuf::from("/b")),
|
||||
"project-2" => Some(PathBuf::from("/c")),
|
||||
"project-3" => Some(PathBuf::from("/d")),
|
||||
// The welcome screen or LightEdit.
|
||||
_ => None,
|
||||
};
|
||||
assert_eq!(
|
||||
session(&groups, &windows, project_of).unwrap(),
|
||||
[
|
||||
SavedWindows { projects: vec!["/b".into(), "/c".into()], selected: 1 },
|
||||
SavedWindows { projects: vec!["/a".into()], selected: 0 },
|
||||
SavedWindows { projects: vec!["/d".into()], selected: 0 },
|
||||
]
|
||||
);
|
||||
// A grouped window still opening its project: keep the previous session.
|
||||
assert_eq!(session(&groups, &windows, |l| (l != "project-2").then(|| PathBuf::from(l))), None);
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -324,7 +324,7 @@ a token.
|
||||
|
||||
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
|
||||
|
||||
224 themeable tokens: 117 in the UI theme file, 107 in the editor colour scheme file.
|
||||
235 themeable tokens: 121 in the UI theme file, 114 in the editor colour scheme file.
|
||||
|
||||
### Surfaces (UI theme)
|
||||
|
||||
@@ -407,6 +407,10 @@ a token.
|
||||
| `--diff-modified` | `#385570` | `#c2d8f2` |
|
||||
| `--diff-inserted` | `#294436` | `#bee6be` |
|
||||
| `--diff-deleted` | `#484a4a` | `#d6d6d6` |
|
||||
| `--diff-modified-stripe` | `#43698d` | `#b6d2f2` |
|
||||
| `--diff-inserted-stripe` | `#447152` | `#aadeaa` |
|
||||
| `--diff-deleted-stripe` | `#656e76` | `#c8c8c8` |
|
||||
| `--diff-conflict-stripe` | `#8f5247` | `#ffc8bd` |
|
||||
|
||||
### Syntax (editor scheme)
|
||||
|
||||
@@ -430,6 +434,13 @@ a token.
|
||||
| `--syn-param` | `#bcbec4` | `#080808` |
|
||||
| `--syn-property` | `#c77dbb` | `#871094` |
|
||||
| `--syn-constant` | `#c77dbb` | `#871094` |
|
||||
| `--syn-python-local-variable` | `var(--syn-variable)` | `var(--syn-variable)` |
|
||||
| `--syn-python-global-variable` | `var(--syn-global)` | `var(--syn-global)` |
|
||||
| `--syn-python-instance-attribute` | `var(--syn-property)` | `var(--syn-property)` |
|
||||
| `--syn-python-class-attribute` | `var(--syn-property)` | `var(--syn-property)` |
|
||||
| `--syn-python-constant` | `var(--syn-constant)` | `var(--syn-constant)` |
|
||||
| `--syn-python-property` | `var(--syn-property)` | `var(--syn-property)` |
|
||||
| `--syn-python-type-parameter` | `var(--syn-type)` | `var(--syn-type)` |
|
||||
| `--syn-builtin` | `#8888c6` | `#000080` |
|
||||
| `--syn-self` | `#94558d` | `#94558d` |
|
||||
| `--syn-keyword-arg` | `#aa4926` | `#660099` |
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* #1539: committing a subset of the changed files must not check the ones left out again.
|
||||
* Git is stubbed at the IPC layer, like `commit.spec.ts`.
|
||||
*/
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, ipcCalls, stubDesktop, test } from "./fixtures";
|
||||
|
||||
const ROOT = "/dev/acme-api";
|
||||
const NAMES = Array.from({ length: 20 }, (_, i) => `src/f${i}.ts`);
|
||||
|
||||
/** Twenty modified tracked files; `vcs_stage` + `vcs_commit` take the staged ones out of the status. */
|
||||
async function stubTwentyChanges(page: Page) {
|
||||
await stubDesktop(page);
|
||||
await page.evaluate(
|
||||
async ({ root, names }) => {
|
||||
const staged = new Set<string>();
|
||||
const committed = new Set<string>();
|
||||
const all = names.map((rel) => ({
|
||||
path: `${root}/${rel}`,
|
||||
relPath: rel,
|
||||
staged: null,
|
||||
unstaged: "modified",
|
||||
renamedFrom: null,
|
||||
}));
|
||||
const h = window.__ipc.handlers;
|
||||
h.vcs_roots = () => [{ root, operation: null, head: "main", headCommit: "1111111" }];
|
||||
h.vcs_branch_info = () => ({
|
||||
head: "main",
|
||||
headCommit: "1111111",
|
||||
upstream: null,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
operation: null,
|
||||
});
|
||||
h.vcs_status = () => all.filter((f) => !committed.has(f.path));
|
||||
h.vcs_stage = (args) => {
|
||||
for (const path of args.paths as string[]) staged.add(path);
|
||||
return null;
|
||||
};
|
||||
h.vcs_unstage = () => null;
|
||||
h.vcs_commit = () => {
|
||||
for (const path of staged) committed.add(path);
|
||||
staged.clear();
|
||||
return "2222222";
|
||||
};
|
||||
const vcsModule = "/src/features/vcs/store.ts";
|
||||
const { useVcs, refreshNow } = await import(/* @vite-ignore */ vcsModule);
|
||||
useVcs.setState({ available: true, root, repo: root, branchesLoaded: false, stashesLoaded: false });
|
||||
await refreshNow();
|
||||
},
|
||||
{ root: ROOT, names: NAMES },
|
||||
);
|
||||
}
|
||||
|
||||
test("changes left out of a commit stay unchecked (#1539)", async ({ workbench: page }, testInfo) => {
|
||||
await stubTwentyChanges(page);
|
||||
await page.keyboard.press("Meta+k");
|
||||
const panel = page.getByRole("region", { name: "Commit", exact: true });
|
||||
const changes = panel.getByRole("tree", { name: "Changes" });
|
||||
|
||||
// Uncheck everything, then pick the 5 files to commit.
|
||||
await changes.getByRole("checkbox", { name: "Include Changes in commit" }).click();
|
||||
for (const name of NAMES.slice(0, 5)) {
|
||||
await changes.getByRole("checkbox", { name: `Include ${name.split("/").pop()} in commit` }).click();
|
||||
}
|
||||
await expect(changes.getByRole("checkbox", { name: "Include f0.ts in commit" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
|
||||
await panel.getByRole("textbox", { name: "Commit message" }).fill("Commit 5 of 20");
|
||||
await panel.getByRole("button", { name: "Commit", exact: true }).click();
|
||||
await expect(page.getByText(/5 files committed: Commit 5 of 20/)).toBeVisible();
|
||||
expect((await ipcCalls(page, "vcs_stage")).flatMap((a) => a.paths as string[]).sort()).toEqual(
|
||||
NAMES.slice(0, 5)
|
||||
.map((rel) => `${ROOT}/${rel}`)
|
||||
.sort(),
|
||||
);
|
||||
|
||||
// The 15 files left out are still in the tree, unchecked: the next commit cannot take them by surprise.
|
||||
const leftOut = changes.getByRole("checkbox", { name: "Include f19.ts in commit" });
|
||||
await expect(leftOut).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("commit-inclusion.png"), animations: "disabled" });
|
||||
for (const name of NAMES.slice(5)) {
|
||||
await expect(changes.getByRole("checkbox", { name: `Include ${name.split("/").pop()} in commit` })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { syntaxHighlighting } from "@codemirror/language";
|
||||
import { openideaHighlightStyle } from "../src/editor/core/cm/highlight";
|
||||
import { findLanguageDescription, loadSupport } from "../src/editor/core/cm/languages";
|
||||
import { semanticExtension } from "../src/editor/lsp/semantic";
|
||||
import { lspDoc } from "../src/editor/lsp/lsp";
|
||||
import { lspTheme } from "../src/editor/lsp/theme";
|
||||
import { parseIcls } from "../src/platform/icls";
|
||||
import { schemeCss } from "../src/platform/colorScheme";
|
||||
import type { EditorDocContext } from "../src/editor/api";
|
||||
|
||||
const attributes = {
|
||||
TEXT: { FOREGROUND: "ddeeff", BACKGROUND: "101820" },
|
||||
"PY.KEYWORD": { FOREGROUND: "112233" },
|
||||
"PY.FUNC_DEFINITION": { FOREGROUND: "224466" },
|
||||
"PY.NESTED_FUNC_DEFINITION": { FOREGROUND: "335577" },
|
||||
DEFAULT_FUNCTION_CALL: { FOREGROUND: "446688" },
|
||||
DEFAULT_LINE_COMMENT: { FOREGROUND: "667788" },
|
||||
DEFAULT_BLOCK_COMMENT: { FOREGROUND: "aabbcc" },
|
||||
"PY.METHOD_CALL": { FOREGROUND: "557799" },
|
||||
"PY.DOC_COMMENT": { FOREGROUND: "6688aa", EFFECT_TYPE: "1" },
|
||||
"PY.STRING.B": { FOREGROUND: "7799bb", FONT_TYPE: "1" },
|
||||
"PY.VALID_STRING_ESCAPE": { FOREGROUND: "88aacc" },
|
||||
"PY.ANNOTATION": { FOREGROUND: "99bbdd", BACKGROUND: "182838" },
|
||||
"PY.SELF_PARAMETER": { FOREGROUND: "aa7788" },
|
||||
"PY.BUILTIN_NAME": { FOREGROUND: "bb8899" },
|
||||
"PY.KEYWORD_ARGUMENT": { FOREGROUND: "cc99aa" },
|
||||
OPENIDEA_pyLocalVariable: { FOREGROUND: "aa1101" },
|
||||
OPENIDEA_pyGlobalVariable: { FOREGROUND: "aa2202" },
|
||||
OPENIDEA_pyInstanceAttribute: { FOREGROUND: "aa3303" },
|
||||
OPENIDEA_pyClassAttribute: { FOREGROUND: "aa4404" },
|
||||
OPENIDEA_pyConstant: { FOREGROUND: "aa5505" },
|
||||
OPENIDEA_pyProperty: { FOREGROUND: "aa6606" },
|
||||
OPENIDEA_pyTypeParameter: { FOREGROUND: "aa7707" },
|
||||
};
|
||||
const xml = `<scheme name="Python" parent_scheme="Darcula"><attributes>${Object.entries(attributes)
|
||||
.map(
|
||||
([key, values]) =>
|
||||
`<option name="${key}"><value>${Object.entries(values)
|
||||
.map(([name, value]) => `<option name="${name}" value="${value}"/>`)
|
||||
.join("")}</value></option>`,
|
||||
)
|
||||
.join("")}</attributes></scheme>`;
|
||||
const style = document.createElement("style");
|
||||
style.textContent =
|
||||
schemeCss("custom-python", {}, null, { "custom-python": parseIcls(xml).scheme }) +
|
||||
"body { margin: 24px; background: #101820; color: #ddeeff; } .cm-editor { font: 14px/1.6 monospace; }";
|
||||
document.head.append(style);
|
||||
const support = await loadSupport(findLanguageDescription("Python", "demo.py")!);
|
||||
new EditorView({
|
||||
parent: document.body,
|
||||
state: EditorState.create({
|
||||
doc: `MAX_SIZE: Final = 10
|
||||
name = "global"
|
||||
T = TypeVar("T")
|
||||
|
||||
def outer(x: T) -> T:
|
||||
"""Syntax Highlighting Demo"""
|
||||
def nested(y):
|
||||
print(y + 1)
|
||||
nested(x)
|
||||
|
||||
class Foo[T]:
|
||||
count = 0
|
||||
def __init__(self: Foo, value: T):
|
||||
value = b"newline:\\n"
|
||||
local = 1
|
||||
self.instance_attr = value
|
||||
print(local, MAX_SIZE, name, T)
|
||||
self.run(whatever=1) # Comment
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return self.instance_attr
|
||||
`,
|
||||
extensions: [
|
||||
support!,
|
||||
syntaxHighlighting(openideaHighlightStyle),
|
||||
lspTheme,
|
||||
lspDoc.of({ language: "python" } as EditorDocContext),
|
||||
...(new URLSearchParams(location.search).has("baseline") ? [] : [semanticExtension()]),
|
||||
],
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="./pythonHighlight.fixture.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,51 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("Python syntax supplements retain PyCharm colours without a language server", async ({ page }, info) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
await page.goto("/e2e/pythonHighlight.html?baseline");
|
||||
await expect(page.locator(".cm-content")).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath("before.png") });
|
||||
await page.goto("/e2e/pythonHighlight.html");
|
||||
await expect(page.locator(".cm-sem-python-definition")).toHaveText("outer");
|
||||
await expect(page.locator(".cm-sem-python-definition")).toHaveCSS("color", "rgb(34, 68, 102)");
|
||||
await expect(page.locator(".cm-sem-python-nested")).toHaveCSS("color", "rgb(51, 85, 119)");
|
||||
await expect(page.locator(".cm-sem-python-docstring")).toHaveCSS("color", "rgb(102, 136, 170)");
|
||||
await expect(page.locator(".cm-sem-python-docstring")).toHaveCSS("text-decoration-line", "none");
|
||||
await expect(page.locator(".cm-sem-python-bytes").first()).toHaveCSS("color", "rgb(119, 153, 187)");
|
||||
await expect(page.locator(".cm-sem-python-bytes").first()).toHaveCSS("font-weight", "700");
|
||||
await expect(page.locator(".cm-sem-python-escape")).toHaveCSS("color", "rgb(136, 170, 204)");
|
||||
await expect(page.locator(".cm-sem-python-annotation").first()).toHaveCSS("color", "rgb(153, 187, 221)");
|
||||
await expect(page.locator(".cm-sem-self").first()).toHaveCSS("color", "rgb(170, 119, 136)");
|
||||
await expect(page.locator(".cm-sem-python-method-call")).toHaveCSS("color", "rgb(85, 119, 153)");
|
||||
await expect(
|
||||
page
|
||||
.locator(".cm-line")
|
||||
.filter({ hasText: "nested(x)" })
|
||||
.locator(".cm-sem-function")
|
||||
.filter({ hasText: /^nested$/ }),
|
||||
).toHaveCSS("color", "rgb(68, 102, 136)");
|
||||
await expect(
|
||||
page
|
||||
.locator(".cm-line")
|
||||
.filter({ hasText: "# Comment" })
|
||||
.locator("span")
|
||||
.filter({ hasText: /^# Comment$/ }),
|
||||
).toHaveCSS("color", "rgb(102, 119, 136)");
|
||||
const print = page
|
||||
.locator(".cm-sem-python-builtin")
|
||||
.filter({ hasText: /^print$/ })
|
||||
.first();
|
||||
await expect(print).toHaveText("print");
|
||||
await expect(print).toHaveCSS("color", "rgb(187, 136, 153)");
|
||||
await expect(page.locator(".cm-sem-python-keyword-arg")).toHaveCSS("color", "rgb(204, 153, 170)");
|
||||
await expect(page.locator(".cm-sem-python-local-variable").first()).toHaveCSS("color", "rgb(170, 17, 1)");
|
||||
await expect(page.locator(".cm-sem-python-global-variable").first()).toHaveCSS("color", "rgb(170, 34, 2)");
|
||||
await expect(page.locator(".cm-sem-python-instance-attribute").first()).toHaveCSS("color", "rgb(170, 51, 3)");
|
||||
await expect(page.locator(".cm-sem-python-class-attribute").first()).toHaveCSS("color", "rgb(170, 68, 4)");
|
||||
await expect(page.locator(".cm-sem-python-constant").first()).toHaveCSS("color", "rgb(170, 85, 5)");
|
||||
await expect(page.locator(".cm-sem-python-property").first()).toHaveCSS("color", "rgb(170, 102, 6)");
|
||||
await expect(page.locator(".cm-sem-python-type-parameter").first()).toHaveCSS("color", "rgb(170, 119, 7)");
|
||||
expect(errors).toEqual([]);
|
||||
await page.screenshot({ path: info.outputPath("python-highlighting.png") });
|
||||
});
|
||||
@@ -29,6 +29,11 @@ test("local terminal stores clipboard media and pastes one shell-quoted argument
|
||||
try {
|
||||
session.attach(container);
|
||||
await session.start();
|
||||
// xterm opens on a later frame, once the pane has a size.
|
||||
const openDeadline = Date.now() + 5000;
|
||||
while (!container.querySelector("textarea") && Date.now() < openDeadline) {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(new File([new Uint8Array([137, 80, 78, 71])], "image.png", { type: "image/png" }));
|
||||
const event = new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: transfer });
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { KnipConfig } from "knip";
|
||||
|
||||
const config: KnipConfig = {
|
||||
// Entries come from the Vite (index.html), Vitest, Playwright and package.json script plugins.
|
||||
entry: ["e2e/pythonHighlight.fixture.ts"],
|
||||
project: [
|
||||
"src/**/*.{ts,tsx,css}",
|
||||
"!src/editor/core/cm/angular/parser.terms.ts",
|
||||
|
||||
@@ -22,6 +22,13 @@
|
||||
"--syn-param": "#d9dfe8",
|
||||
"--syn-property": "#b9a3f0",
|
||||
"--syn-constant": "#e58fa3",
|
||||
"--syn-python-local-variable": "#d9dfe8",
|
||||
"--syn-python-global-variable": "#d9dfe8",
|
||||
"--syn-python-instance-attribute": "#b9a3f0",
|
||||
"--syn-python-class-attribute": "#b9a3f0",
|
||||
"--syn-python-constant": "#e58fa3",
|
||||
"--syn-python-property": "#b9a3f0",
|
||||
"--syn-python-type-parameter": "#8fd4c9",
|
||||
"--syn-builtin": "#6fc3df",
|
||||
"--syn-self": "#d6849c",
|
||||
"--syn-keyword-arg": "#c4a0f2",
|
||||
|
||||
@@ -67,6 +67,10 @@
|
||||
"--diff-modified": "#2f4f63",
|
||||
"--diff-inserted": "#294436",
|
||||
"--diff-deleted": "#43494f",
|
||||
"--diff-modified-stripe": "#3a6380",
|
||||
"--diff-inserted-stripe": "#447152",
|
||||
"--diff-deleted-stripe": "#5f676f",
|
||||
"--diff-conflict-stripe": "#8f5247",
|
||||
|
||||
// Shadows and window controls
|
||||
"--shadow-popup": "0 6px 20px rgba(0, 0, 0, 0.45)",
|
||||
|
||||
@@ -85,10 +85,8 @@ pub async fn open_project(window: Window, path: PathBuf) -> IdeResult<ProjectInf
|
||||
let info = project_info(&project);
|
||||
let _ = window.set_title(&info.name);
|
||||
window.state::<AppState>().sink.emit(ide_core::IdeEvent::WindowGroupsChanged);
|
||||
let grouped = window.state::<crate::windows::WindowsState>().groups.lock().group(window.label()).is_some();
|
||||
if grouped {
|
||||
crate::window_groups::persist(window.app_handle()).await?;
|
||||
}
|
||||
// Every project window is part of the session reopened at the next start.
|
||||
crate::window_groups::persist(window.app_handle()).await?;
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
|
||||
@@ -433,44 +433,36 @@ fn close_next(app: &AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct SavedGroup {
|
||||
projects: Vec<PathBuf>,
|
||||
selected: usize,
|
||||
/// Window labels in creation order: `main`, then `project-1`, `project-2`, ….
|
||||
fn labels_in_order(app: &AppHandle) -> Vec<String> {
|
||||
let mut labels: Vec<String> = app.webview_windows().into_keys().collect();
|
||||
labels.sort_by_key(|l| (l != "main", l.rsplit('-').next().and_then(|n| n.parse::<u32>().ok()), l.clone()));
|
||||
labels
|
||||
}
|
||||
|
||||
/// Saves the open project windows (groups and single windows) for "Reopen projects on startup".
|
||||
/// Nothing is written while quitting, so the windows open at quit are the ones reopened.
|
||||
pub async fn persist(app: &AppHandle) -> IdeResult<()> {
|
||||
let state = app.state::<WindowsState>();
|
||||
let _write = state.groups_persist.lock().await;
|
||||
let mut previous = state.groups_persist.lock().await;
|
||||
if state.quitting() {
|
||||
return Ok(());
|
||||
}
|
||||
let groups = state.groups.lock().groups().to_vec();
|
||||
let host = app.state::<AppState>();
|
||||
let saved: Vec<_> = groups
|
||||
.iter()
|
||||
.filter_map(|group| {
|
||||
let projects: Vec<_> = group
|
||||
.members
|
||||
.iter()
|
||||
.filter_map(|label| {
|
||||
host.project(label)
|
||||
.ok()
|
||||
.map(|p| p.root.clone())
|
||||
.or_else(|| state.pending(label).and_then(|l| l.project).map(Into::into))
|
||||
})
|
||||
.collect();
|
||||
(projects.len() == group.members.len()).then(|| SavedGroup {
|
||||
projects,
|
||||
selected: group.members.iter().position(|l| l == &group.selected).unwrap_or(0),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// A restored member may have taken its launch but not finished opening yet.
|
||||
if saved.len() != groups.len() {
|
||||
return Ok(());
|
||||
}
|
||||
let path = host.config_dir.join("window-groups.json");
|
||||
if previous.is_none() {
|
||||
*previous = Some(read_session(path.clone()).await);
|
||||
}
|
||||
let groups = state.groups.lock().groups().to_vec();
|
||||
let windows = labels_in_order(app);
|
||||
let project_of = |label: &str| match host.project(label) {
|
||||
Ok(p) => (!p.light).then(|| p.root.clone()),
|
||||
// A restored window may have taken its launch but not finished opening yet.
|
||||
Err(_) => state.pending(label).and_then(|l| l.project).map(Into::into),
|
||||
};
|
||||
let Some(saved) = ide_workspace::window_groups::session(&groups, &windows, project_of) else {
|
||||
return Ok(());
|
||||
};
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let bytes = serde_json::to_vec(&saved).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
ide_vfs::write_atomic(&path, &bytes).map(|_| ())
|
||||
@@ -479,6 +471,22 @@ pub async fn persist(app: &AppHandle) -> IdeResult<()> {
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// The saved session; empty when there is none or it can't be read.
|
||||
async fn read_session(path: PathBuf) -> Vec<ide_workspace::window_groups::SavedWindows> {
|
||||
let read = tauri::async_runtime::spawn_blocking(move || match std::fs::read(&path) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| format!("{}: {e}", path.display())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
|
||||
Err(e) => Err(format!("{}: {e}", path.display())),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|r| r);
|
||||
read.unwrap_or_else(|e| {
|
||||
tracing::warn!("cannot read the saved windows: {e}");
|
||||
Vec::new()
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn restore(app: &AppHandle, caller: &str) -> IdeResult<()> {
|
||||
if caller != "main" {
|
||||
return Ok(());
|
||||
@@ -489,41 +497,51 @@ pub async fn restore(app: &AppHandle, caller: &str) -> IdeResult<()> {
|
||||
if !project.settings.effective().general.reopen_last_project || state.groups_restored.swap(true, Ordering::SeqCst) {
|
||||
return Ok(());
|
||||
}
|
||||
let path = host.config_dir.join("window-groups.json");
|
||||
let saved: Vec<SavedGroup> = tauri::async_runtime::spawn_blocking(move || -> IdeResult<_> {
|
||||
match std::fs::read(path) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| IdeError::invalid(e.to_string())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
|
||||
Err(e) => Err(e.into()),
|
||||
let saved = {
|
||||
let mut previous = state.groups_persist.lock().await;
|
||||
if previous.is_none() {
|
||||
*previous = Some(read_session(host.config_dir.join("window-groups.json")).await);
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))??;
|
||||
if let Some(group) = saved.iter().find(|g| g.projects.contains(&project.root)) {
|
||||
previous.clone().unwrap_or_default()
|
||||
};
|
||||
// The caller's own group first (it already shows one of its projects), then every other
|
||||
// group and single window that was open at quit. Missing folders are skipped.
|
||||
let order = saved
|
||||
.iter()
|
||||
.filter(|g| g.projects.contains(&project.root))
|
||||
.chain(saved.iter().filter(|g| !g.projects.contains(&project.root)));
|
||||
for group in order {
|
||||
let mut labels = Vec::new();
|
||||
let mut selected = caller.to_owned();
|
||||
let mut selected = None;
|
||||
for path in &group.projects {
|
||||
let label = if path == &project.root {
|
||||
Some(caller.to_owned())
|
||||
} else if path.is_dir() {
|
||||
let window = match windows::find_project_window(app, path) {
|
||||
Some(w) => w,
|
||||
None => windows::create_restored_window(app, path.clone())?,
|
||||
Some(w) => Ok(w),
|
||||
None => windows::create_restored_window(app, path.clone()),
|
||||
};
|
||||
Some(window.label().to_owned())
|
||||
match window {
|
||||
Ok(w) => Some(w.label().to_owned()),
|
||||
Err(e) => {
|
||||
tracing::warn!("cannot reopen {}: {e}", path.display());
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(label) = label {
|
||||
if group.projects.get(group.selected) == Some(path) {
|
||||
selected = label.clone();
|
||||
selected = Some(label.clone());
|
||||
}
|
||||
labels.push(label);
|
||||
}
|
||||
}
|
||||
if labels.len() > 1 {
|
||||
state.groups.lock().merge(labels, caller)?;
|
||||
select(app, caller, &selected)?;
|
||||
let selected = selected.unwrap_or_else(|| labels[0].clone());
|
||||
state.groups.lock().merge(labels, &selected)?;
|
||||
select(app, &selected, &selected)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -120,7 +120,9 @@ pub fn wsl_launch(path: &Path) -> Option<RemoteLaunch> {
|
||||
pub struct WindowsState {
|
||||
pub groups: Mutex<ide_workspace::window_groups::WindowGroups>,
|
||||
pub groups_restored: AtomicBool,
|
||||
pub groups_persist: tokio::sync::Mutex<()>,
|
||||
/// Serialises writes of the saved session. Holds the previous run's session once read: it is
|
||||
/// read before this run first overwrites the file, so restoring still sees it.
|
||||
pub groups_persist: tokio::sync::Mutex<Option<Vec<ide_workspace::window_groups::SavedWindows>>>,
|
||||
launches: Mutex<HashMap<String, Launch>>,
|
||||
/// Caret positions asked for on the command line, by canonical file path, until a window takes the file.
|
||||
positions: Mutex<HashMap<PathBuf, (u32, u32)>>,
|
||||
|
||||
@@ -2426,6 +2426,8 @@ export type CoverageReportSpec =
|
||||
{ kind: "lcov"; runner: string; path: IpcPath; base: IpcPath } |
|
||||
/** `go test -coverprofile`; import paths under `module` map to `module_dir`. */
|
||||
{ kind: "goProfile"; path: IpcPath; module: string; moduleDir: IpcPath } |
|
||||
/** SimpleCov's `.resultset.json`; relative paths resolve against `base`. */
|
||||
{ kind: "simpleCov"; path: IpcPath; base: IpcPath } |
|
||||
/** Every JaCoCo XML report (Maven `target/site/jacoco`, Gradle `build/reports/jacoco`) under `dir`. */
|
||||
{ kind: "jacoco"; dir: IpcPath } |
|
||||
/** The tool's raw data is exported to lcov by `argv` first (coverage.py: `… -m coverage lcov -o <lcov>`). */
|
||||
@@ -5514,6 +5516,12 @@ export type PhpSettings = {
|
||||
* `composer.json` (`wordpress`).
|
||||
*/
|
||||
stubs?: string[],
|
||||
/**
|
||||
* PHPStan configuration file for checking saved files; empty = the `phpstan.neon`,
|
||||
* `phpstan.neon.dist` or `phpstan.dist.neon` PHPStan finds in the project root. Absolute,
|
||||
* `~/`, or relative to the project.
|
||||
*/
|
||||
phpstanConfig?: string,
|
||||
};
|
||||
|
||||
/** The focused editor when a plugin command is invoked. */
|
||||
|
||||
+6
-2
@@ -238,8 +238,12 @@ export interface ScrollbarMark {
|
||||
to?: number;
|
||||
/** CSS colour, normally a token such as `var(--danger)`. */
|
||||
color: string;
|
||||
/** `line` (default): 4px-wide bar on the lane; `thin`: 2px bar (search hits, occurrences). */
|
||||
kind?: "line" | "thin";
|
||||
/**
|
||||
* `line` (default): 4px-wide bar on the lane; `thin`: 2px bar (search hits, occurrences);
|
||||
* `full`: the lane's whole width, from the top of the first line to the top of the last (IDEA's
|
||||
* diff and merge error stripe, so a one-line change is a min-height dash).
|
||||
*/
|
||||
kind?: "line" | "thin" | "full";
|
||||
/** Shown on hover. */
|
||||
tooltip?: string;
|
||||
/** Higher wins when marks overlap. Default 0. */
|
||||
|
||||
+81
-14
@@ -62,7 +62,7 @@ import { isColumnMode, toggleColumnMode } from "./cm/columnMode";
|
||||
import { cloneCaretAbove, cloneCaretBelow } from "./cm/verticalMotion";
|
||||
import { pasteAsPlainText, pasteFromHistory, unwrapAtCaret } from "./extras";
|
||||
import { cyclicExpandWord } from "./wordCompletion";
|
||||
import { qualifiedReference } from "./copyReference";
|
||||
import { copyReferenceToClipboard, qualifiedReference, referenceOffset } from "./copyReference";
|
||||
import { declarationChain } from "./view/outline";
|
||||
import { recordClipboard } from "./clipboardHistory";
|
||||
import { useDocMeta, useOutline } from "./model/document";
|
||||
@@ -84,6 +84,8 @@ import {
|
||||
import type { DocModel } from "./model/document";
|
||||
import { structure } from "@/editor/structure";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { lspRequest } from "@/editor/lsp/lsp";
|
||||
import { readFileLines, withDocument } from "@/editor/lsp/files";
|
||||
|
||||
/** Text-editing actions: the editor has focus, or a popup (Find Action) runs them on it. */
|
||||
export const TEXT_WHEN = "editorTextFocus || textEditorActive && popupOpen";
|
||||
@@ -408,25 +410,90 @@ function expandWord(view: EditorView, slot: EditorSlot, direction: "backward" |
|
||||
if (message) void executeAction("editor.showHint", { text: message });
|
||||
}
|
||||
|
||||
function copyReference(view: EditorView, slot: EditorSlot) {
|
||||
async function resolveReference(view: EditorView, slot: EditorSlot): Promise<string> {
|
||||
const state = view.state;
|
||||
const head = state.selection.main.head;
|
||||
const line = state.doc.lineAt(head);
|
||||
const selection = state.selection.main;
|
||||
const line = state.doc.lineAt(selection.from);
|
||||
const meta = slot.model.getMeta();
|
||||
const outline = useOutline.getState()[slot.model.path] ?? [];
|
||||
// A package is not a member to qualify: jdtls lists the whole `package …;` statement as one.
|
||||
const chain = declarationChain(outline, line.number - 1, head - line.from).filter((i) => i.kind !== "package");
|
||||
const reference = qualifiedReference({
|
||||
const root = useProject.getState().project?.root ?? null;
|
||||
const fallback = qualifiedReference({
|
||||
language: meta.language,
|
||||
path: slot.model.path,
|
||||
root: useProject.getState().project?.root ?? null,
|
||||
text: state.doc.toString(),
|
||||
chain: chain.map((i) => i.name),
|
||||
root,
|
||||
text: "",
|
||||
chain: [],
|
||||
line: line.number - 1,
|
||||
});
|
||||
recordClipboard(reference);
|
||||
void navigator.clipboard?.writeText(reference).catch(() => {});
|
||||
void executeAction("editor.showHint", { text: tx`Copied reference: ${reference}` });
|
||||
let reference = fallback;
|
||||
const offset = referenceOffset(state.doc.toString(), selection.from, selection.to);
|
||||
if (offset !== null) {
|
||||
const position = state.doc.lineAt(offset);
|
||||
const localChain = declarationChain(
|
||||
useOutline.getState()[slot.model.path] ?? [],
|
||||
position.number - 1,
|
||||
offset - position.from,
|
||||
).filter((item) => item.kind !== "package");
|
||||
if (localChain.length) {
|
||||
reference = qualifiedReference({
|
||||
language: meta.language,
|
||||
path: slot.model.path,
|
||||
root,
|
||||
text: state.doc.toString(),
|
||||
chain: localChain.map((item) => item.name),
|
||||
line: position.number - 1,
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
const targets = await lspRequest(() =>
|
||||
commands.lspGoto(meta.doc, { line: position.number - 1, col: offset - position.from }, "definition"),
|
||||
);
|
||||
if (targets.length === 1) {
|
||||
const target = targets[0]!;
|
||||
const [outline, lines] = await Promise.all([
|
||||
withDocument(target.path, (doc) => call(commands.docOutline(doc))),
|
||||
readFileLines(target.path),
|
||||
]);
|
||||
const pos = target.range.start;
|
||||
const chain = declarationChain(outline, pos.line, pos.col).filter((item) => item.kind !== "package");
|
||||
const symbol = chain.at(-1);
|
||||
if (symbol && ["function", "method", "constructor"].includes(symbol.kind) && lines) {
|
||||
reference = qualifiedReference({
|
||||
language: meta.language,
|
||||
path: target.path,
|
||||
root,
|
||||
text: lines.join("\n"),
|
||||
chain: chain.map((item) => item.name),
|
||||
line: pos.line,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// No unambiguous definition is available; copy the current location.
|
||||
}
|
||||
}
|
||||
}
|
||||
return reference;
|
||||
}
|
||||
|
||||
function copyReference(view: EditorView, slot: EditorSlot) {
|
||||
const reference = resolveReference(view, slot);
|
||||
const failed = (error: unknown) => {
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: tx("Cannot copy reference"),
|
||||
body: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
};
|
||||
const copied = async () => {
|
||||
const text = await reference;
|
||||
recordClipboard(text);
|
||||
void executeAction("editor.showHint", { text: tx`Copied reference: ${text}` });
|
||||
};
|
||||
try {
|
||||
void copyReferenceToClipboard(reference).then(copied).catch(failed);
|
||||
} catch (error) {
|
||||
failed(error);
|
||||
}
|
||||
}
|
||||
|
||||
/** ⌃G; when nothing more is found, a hint first and the search from the top on the next press (IDEA). */
|
||||
|
||||
+53
-17
@@ -58,8 +58,14 @@ export function unfoldAround(state: EditorState, pos: number): StateEffect<unkno
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The class an outer scroller gets while a lane draws its scrollbar (its native one is hidden). */
|
||||
const OUTER_SCROLLER = "cm-openidea-overviewScroller";
|
||||
|
||||
class OverviewLane {
|
||||
dom: HTMLElement;
|
||||
/** What scrolls the document: the editor's own scroller, or an outer one the editor is laid out in. */
|
||||
private scroller: HTMLElement;
|
||||
private resize: ResizeObserver | null = null;
|
||||
private scheduled = false;
|
||||
private lastKey = "";
|
||||
/** The last pointer position over the editor, to re-check the thumb when it scrolls. */
|
||||
@@ -70,9 +76,13 @@ class OverviewLane {
|
||||
*/
|
||||
private thumb: HTMLElement;
|
||||
|
||||
constructor(private view: EditorView) {
|
||||
constructor(
|
||||
private view: EditorView,
|
||||
outer?: HTMLElement,
|
||||
) {
|
||||
this.scroller = outer ?? view.scrollDOM;
|
||||
this.dom = document.createElement("div");
|
||||
this.dom.className = "cm-openidea-overview";
|
||||
this.dom.className = outer ? "cm-openidea-overview cm-openidea-overview-sticky" : "cm-openidea-overview";
|
||||
this.dom.setAttribute("aria-hidden", "true");
|
||||
this.thumb = document.createElement("div");
|
||||
this.thumb.className = "cm-openidea-overviewThumb";
|
||||
@@ -80,8 +90,14 @@ class OverviewLane {
|
||||
this.thumb.addEventListener("mousedown", this.onThumbMouseDown);
|
||||
view.dom.addEventListener("mousemove", this.onPointerMove);
|
||||
view.dom.addEventListener("mouseleave", this.onPointerLeave);
|
||||
view.scrollDOM.addEventListener("scroll", this.onScroll, { passive: true });
|
||||
view.dom.appendChild(this.dom);
|
||||
this.scroller.addEventListener("scroll", this.onScroll, { passive: true });
|
||||
if (outer) {
|
||||
// First in the editor, sticking to the top of the outer scroller's viewport.
|
||||
view.dom.prepend(this.dom);
|
||||
outer.classList.add(OUTER_SCROLLER);
|
||||
this.resize = new ResizeObserver(() => this.schedule());
|
||||
this.resize.observe(outer);
|
||||
} else view.dom.appendChild(this.dom);
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
@@ -104,7 +120,7 @@ class OverviewLane {
|
||||
key: this,
|
||||
read: (view) => {
|
||||
this.scheduled = false;
|
||||
const laneHeight = view.scrollDOM.clientHeight;
|
||||
const laneHeight = this.scroller.clientHeight;
|
||||
const len = view.state.doc.length;
|
||||
// The document itself, without the scroll-past-end padding that follows the editor height.
|
||||
const contentHeight = Math.max(view.lineBlockAt(len).bottom, 1);
|
||||
@@ -125,7 +141,10 @@ class OverviewLane {
|
||||
// Marks landing on the same lane pixel collapse into one.
|
||||
const px = Math.floor(a.top * scale);
|
||||
const top = pos(a.top);
|
||||
const height = fit ? Math.round(b.bottom - a.top) : +(((b.bottom - a.top) / contentHeight) * 100).toFixed(3);
|
||||
// A `full` mark runs from the top of its first line to the top of its last, as IDEA's
|
||||
// error stripe (EditorMarkupModelImpl.offsetsToYPositions): one line is a min-height dash.
|
||||
const span = (m.kind === "full" ? b.top : b.bottom) - a.top;
|
||||
const height = fit ? Math.round(span) : +((span / contentHeight) * 100).toFixed(3);
|
||||
const key = `${px}:${m.color}:${m.kind ?? "line"}`;
|
||||
const prev = rows.get(key);
|
||||
if (!prev || (m.priority ?? 0) > (prev.mark.priority ?? 0) || height > prev.height)
|
||||
@@ -136,19 +155,25 @@ class OverviewLane {
|
||||
rows: [...rows.values()].sort((x, y) => (x.mark.priority ?? 0) - (y.mark.priority ?? 0)),
|
||||
caret,
|
||||
unit,
|
||||
laneHeight,
|
||||
};
|
||||
},
|
||||
write: ({ rows, caret, unit }) => {
|
||||
write: ({ rows, caret, unit, laneHeight }) => {
|
||||
if (this.scroller !== this.view.scrollDOM) {
|
||||
this.dom.style.height = `${laneHeight}px`;
|
||||
this.dom.style.marginBottom = `-${laneHeight}px`;
|
||||
}
|
||||
const key =
|
||||
rows.map((r) => `${r.top},${r.height},${r.mark.color},${r.mark.kind}`).join("|") + `#${caret}${unit}`;
|
||||
rows.map((r) => `${r.top},${r.height},${r.mark.color},${r.mark.kind}`).join("|") +
|
||||
`#${caret}${unit}${laneHeight}`;
|
||||
if (key === this.lastKey) return;
|
||||
this.lastKey = key;
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const r of rows) {
|
||||
const el = document.createElement("div");
|
||||
el.className =
|
||||
r.mark.kind === "thin"
|
||||
? "cm-openidea-overviewMark cm-openidea-overviewMark-thin"
|
||||
r.mark.kind === "thin" || r.mark.kind === "full"
|
||||
? `cm-openidea-overviewMark cm-openidea-overviewMark-${r.mark.kind}`
|
||||
: "cm-openidea-overviewMark";
|
||||
el.style.top = `${r.top}${unit}`;
|
||||
el.style.height = `${r.height}${unit}`;
|
||||
@@ -171,8 +196,8 @@ class OverviewLane {
|
||||
private scheduleThumb() {
|
||||
this.view.requestMeasure({
|
||||
key: this.thumb,
|
||||
read: (view) => {
|
||||
const s = view.scrollDOM;
|
||||
read: () => {
|
||||
const s = this.scroller;
|
||||
const thumb = scrollbarThumb(s.scrollTop, s.scrollHeight, s.clientHeight);
|
||||
// The lane starts at the top of the editor, the scrollbar at the top of the scroller.
|
||||
const offset = s.getBoundingClientRect().top - this.dom.getBoundingClientRect().top;
|
||||
@@ -197,7 +222,7 @@ class OverviewLane {
|
||||
let over = false;
|
||||
if (p) {
|
||||
const lane = this.dom.getBoundingClientRect();
|
||||
const s = this.view.scrollDOM;
|
||||
const s = this.scroller;
|
||||
const thumb = scrollbarThumb(s.scrollTop, s.scrollHeight, s.clientHeight);
|
||||
const y = p.y - s.getBoundingClientRect().top;
|
||||
over = !!thumb && p.x >= lane.left && p.x <= lane.right && y >= thumb.top && y <= thumb.bottom;
|
||||
@@ -226,7 +251,7 @@ class OverviewLane {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const s = this.view.scrollDOM;
|
||||
const s = this.scroller;
|
||||
const thumb = scrollbarThumb(s.scrollTop, s.scrollHeight, s.clientHeight);
|
||||
if (!thumb) return;
|
||||
const startY = e.clientY;
|
||||
@@ -255,7 +280,7 @@ class OverviewLane {
|
||||
const hit = nearestMark(marks, e.clientY);
|
||||
if (!hit) {
|
||||
// Above / below the thumb pages the editor, like the native track it replaces.
|
||||
const s = this.view.scrollDOM;
|
||||
const s = this.scroller;
|
||||
const thumb = scrollbarThumb(s.scrollTop, s.scrollHeight, s.clientHeight);
|
||||
e.preventDefault();
|
||||
if (!thumb) return;
|
||||
@@ -280,12 +305,23 @@ class OverviewLane {
|
||||
this.thumb.removeEventListener("mousedown", this.onThumbMouseDown);
|
||||
this.view.dom.removeEventListener("mousemove", this.onPointerMove);
|
||||
this.view.dom.removeEventListener("mouseleave", this.onPointerLeave);
|
||||
this.view.scrollDOM.removeEventListener("scroll", this.onScroll);
|
||||
this.scroller.removeEventListener("scroll", this.onScroll);
|
||||
this.resize?.disconnect();
|
||||
if (this.scroller !== this.view.scrollDOM) this.scroller.classList.remove(OUTER_SCROLLER);
|
||||
this.dom.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The overview lane over an outer scroller that holds the editor at its full height (the panes of
|
||||
* an aligned side-by-side diff scroll together in their container): `scroller` finds it once the
|
||||
* editor is in the DOM. The lane sticks to the right edge of this editor.
|
||||
*/
|
||||
export function overviewLaneIn(scroller: (view: EditorView) => HTMLElement | null): Extension {
|
||||
return ViewPlugin.define((view) => new OverviewLane(view, scroller(view) ?? undefined));
|
||||
}
|
||||
|
||||
export const overviewLane: Extension = [
|
||||
ViewPlugin.fromClass(OverviewLane),
|
||||
ViewPlugin.define((view) => new OverviewLane(view)),
|
||||
EditorView.editorAttributes.of({ class: "cm-openidea-withOverview" }),
|
||||
];
|
||||
+55
-43
@@ -64,6 +64,60 @@ export const caretStyles = {
|
||||
},
|
||||
};
|
||||
|
||||
/** The error stripe and scrollbar lane (cm/lanes.ts), also for the VCS viewers. */
|
||||
export const overviewStyles = {
|
||||
".cm-openidea-overview": {
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
right: "0",
|
||||
bottom: "0",
|
||||
width: "12px",
|
||||
cursor: "default",
|
||||
zIndex: "3",
|
||||
},
|
||||
// Over an outer scroller (an aligned diff): stays at the top of its viewport, taking no room.
|
||||
".cm-openidea-overview-sticky": {
|
||||
position: "sticky",
|
||||
bottom: "auto",
|
||||
flex: "none",
|
||||
alignSelf: "flex-end",
|
||||
},
|
||||
".cm-openidea-overviewMark": {
|
||||
position: "absolute",
|
||||
left: "3px",
|
||||
width: "6px",
|
||||
minHeight: "3px",
|
||||
borderRadius: "1px",
|
||||
pointerEvents: "auto",
|
||||
cursor: "pointer",
|
||||
},
|
||||
".cm-openidea-overviewMark-thin": { left: "4px", width: "4px", minHeight: "2px" },
|
||||
".cm-openidea-overviewMark-full": { left: "0", width: "100%", minHeight: "2px", borderRadius: "0" },
|
||||
// The scrollbar thumb wins over the marks under it (cm/lanes.ts, #752).
|
||||
".cm-openidea-overview-onThumb .cm-openidea-overviewMark": { pointerEvents: "none" },
|
||||
// The lane owns the scrollbar and its thumb (cm/lanes.ts): the native one is hidden so a press
|
||||
// on the scrollbar can never reach the text layer and start a text selection.
|
||||
"&.cm-openidea-withOverview .cm-scroller::-webkit-scrollbar:vertical": { width: "0" },
|
||||
".cm-openidea-overviewThumb": {
|
||||
position: "absolute",
|
||||
right: "2px",
|
||||
width: "6px",
|
||||
borderRadius: "3px",
|
||||
background: "var(--scrollbar-thumb)",
|
||||
pointerEvents: "auto",
|
||||
cursor: "default",
|
||||
},
|
||||
".cm-openidea-overview-onThumb .cm-openidea-overviewThumb": { background: "var(--scrollbar-thumb-hover)" },
|
||||
".cm-openidea-overviewCaret": {
|
||||
position: "absolute",
|
||||
left: "2px",
|
||||
right: "2px",
|
||||
height: "2px",
|
||||
pointerEvents: "none",
|
||||
background: "color-mix(in srgb, var(--fg) 30%, transparent)",
|
||||
},
|
||||
};
|
||||
|
||||
export const openideaTheme = EditorView.theme({
|
||||
"&": {
|
||||
height: "100%",
|
||||
@@ -267,49 +321,7 @@ export const openideaTheme = EditorView.theme({
|
||||
".cm-panels.cm-panels-top": { borderBottom: "1px solid var(--border)" },
|
||||
".cm-panels.cm-panels-bottom": { borderTop: "1px solid var(--border)" },
|
||||
|
||||
// Error stripe and scrollbar lane (cm/lanes.ts)
|
||||
".cm-openidea-overview": {
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
right: "0",
|
||||
bottom: "0",
|
||||
width: "12px",
|
||||
cursor: "default",
|
||||
zIndex: "3",
|
||||
},
|
||||
".cm-openidea-overviewMark": {
|
||||
position: "absolute",
|
||||
left: "3px",
|
||||
width: "6px",
|
||||
minHeight: "3px",
|
||||
borderRadius: "1px",
|
||||
pointerEvents: "auto",
|
||||
cursor: "pointer",
|
||||
},
|
||||
".cm-openidea-overviewMark-thin": { left: "4px", width: "4px", minHeight: "2px" },
|
||||
// The scrollbar thumb wins over the marks under it (cm/lanes.ts, #752).
|
||||
".cm-openidea-overview-onThumb .cm-openidea-overviewMark": { pointerEvents: "none" },
|
||||
// The lane owns the scrollbar and its thumb (cm/lanes.ts): the native one is hidden so a press
|
||||
// on the scrollbar can never reach the text layer and start a text selection.
|
||||
"&.cm-openidea-withOverview .cm-scroller::-webkit-scrollbar:vertical": { width: "0" },
|
||||
".cm-openidea-overviewThumb": {
|
||||
position: "absolute",
|
||||
right: "2px",
|
||||
width: "6px",
|
||||
borderRadius: "3px",
|
||||
background: "var(--scrollbar-thumb)",
|
||||
pointerEvents: "auto",
|
||||
cursor: "default",
|
||||
},
|
||||
".cm-openidea-overview-onThumb .cm-openidea-overviewThumb": { background: "var(--scrollbar-thumb-hover)" },
|
||||
".cm-openidea-overviewCaret": {
|
||||
position: "absolute",
|
||||
left: "2px",
|
||||
right: "2px",
|
||||
height: "2px",
|
||||
pointerEvents: "none",
|
||||
background: "color-mix(in srgb, var(--fg) 30%, transparent)",
|
||||
},
|
||||
...overviewStyles,
|
||||
|
||||
// Sticky lines (cm/stickyScroll.ts)
|
||||
".cm-openidea-sticky": {
|
||||
|
||||
@@ -17,6 +17,27 @@ export interface ReferenceInput {
|
||||
line: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the write during the user gesture, while the reference is still being resolved.
|
||||
* Without `ClipboardItem`, the text is written once it is known.
|
||||
*/
|
||||
export function copyReferenceToClipboard(
|
||||
reference: Promise<string>,
|
||||
clipboard: Clipboard = navigator.clipboard,
|
||||
Item: typeof ClipboardItem | null = globalThis.ClipboardItem ?? null,
|
||||
): Promise<void> {
|
||||
if (!Item || typeof clipboard.write !== "function") return reference.then((text) => clipboard.writeText(text));
|
||||
return clipboard.write([
|
||||
new Item({ "text/plain": reference.then((text) => new Blob([text], { type: "text/plain" })) }),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Position inside a selected identifier or the identifier under the caret. */
|
||||
export function referenceOffset(text: string, from: number, to: number): number | null {
|
||||
if (from !== to) return /^[\p{L}_$][\p{L}\p{N}_$]*$/u.test(text.slice(from, to)) ? from : null;
|
||||
return /[\p{L}\p{N}_$]/u.test(text[from] ?? "") ? from : null;
|
||||
}
|
||||
|
||||
export function relativePath(path: string, root: string | null): string {
|
||||
return root && path.startsWith(root + "/") ? path.slice(root.length + 1) : path;
|
||||
}
|
||||
@@ -49,7 +70,8 @@ export function pythonModulePath(path: string, root: string | null): string {
|
||||
const parts = relativePath(path, root)
|
||||
.replace(/\.pyi?$/, "")
|
||||
.split("/");
|
||||
if (parts[0] === "src" && parts.length > 1) parts.shift();
|
||||
const src = parts.lastIndexOf("src");
|
||||
if (src >= 0 && src < parts.length - 1) parts.splice(0, src + 1);
|
||||
if (parts[parts.length - 1] === "__init__") parts.pop();
|
||||
return parts.join(".");
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { cyclicExpandWord, expandCandidates, prefixStart } from "./wordCompletion";
|
||||
import type { OutlineItem } from "@/platform/ipc";
|
||||
import { pythonModulePath, qualifiedReference, rustImplType, rustModulePath } from "./copyReference";
|
||||
import {
|
||||
copyReferenceToClipboard,
|
||||
pythonModulePath,
|
||||
qualifiedReference,
|
||||
referenceOffset,
|
||||
rustImplType,
|
||||
rustModulePath,
|
||||
} from "./copyReference";
|
||||
import { declarationChain } from "./view/outline";
|
||||
|
||||
describe("cyclic expand word", () => {
|
||||
@@ -52,6 +59,39 @@ describe("cyclic expand word", () => {
|
||||
describe("copy reference", () => {
|
||||
const base = { root: "/p", line: 4 };
|
||||
|
||||
it("starts the clipboard write before asynchronous symbol resolution completes", async () => {
|
||||
let finish!: (value: string) => void;
|
||||
const reference = new Promise<string>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
let content: Promise<Blob> | undefined;
|
||||
class Item {
|
||||
constructor(data: Record<string, Promise<Blob>>) {
|
||||
content = data["text/plain"];
|
||||
}
|
||||
}
|
||||
let writes = 0;
|
||||
const write = () => {
|
||||
writes++;
|
||||
return Promise.resolve();
|
||||
};
|
||||
const result = copyReferenceToClipboard(
|
||||
reference,
|
||||
{ write } as unknown as Clipboard,
|
||||
Item as unknown as typeof ClipboardItem,
|
||||
);
|
||||
expect(writes).toBe(1);
|
||||
finish("coordination_layer.db.key_scope.load_scoped");
|
||||
expect(await (await content!).text()).toBe("coordination_layer.db.key_scope.load_scoped");
|
||||
await result;
|
||||
});
|
||||
|
||||
it("writes the resolved text where ClipboardItem is unavailable", async () => {
|
||||
const writeText = vi.fn(() => Promise.resolve());
|
||||
await copyReferenceToClipboard(Promise.resolve("pkg.mod.f"), { writeText } as unknown as Clipboard, null);
|
||||
expect(writeText).toHaveBeenCalledExactlyOnceWith("pkg.mod.f");
|
||||
});
|
||||
|
||||
it("qualifies Java members with the package", () => {
|
||||
const text = "package com.acme.util;\n\nclass Strings {}";
|
||||
expect(
|
||||
@@ -84,6 +124,9 @@ describe("copy reference", () => {
|
||||
|
||||
it("uses dotted module paths for Python and package names for Go", () => {
|
||||
expect(pythonModulePath("/p/src/pkg/mod.py", "/p")).toBe("pkg.mod");
|
||||
expect(pythonModulePath("/p/services/live/src/coordination_layer/db/key_scope.py", "/p")).toBe(
|
||||
"coordination_layer.db.key_scope",
|
||||
);
|
||||
expect(pythonModulePath("/p/pkg/__init__.py", "/p")).toBe("pkg");
|
||||
expect(
|
||||
qualifiedReference({ ...base, language: "python", path: "/p/pkg/mod.py", text: "", chain: ["C", "m"] }),
|
||||
@@ -94,6 +137,10 @@ describe("copy reference", () => {
|
||||
});
|
||||
|
||||
it("falls back to path:line", () => {
|
||||
expect(qualifiedReference({ ...base, language: "python", path: "/p/a.py", text: "", chain: [] })).toBe("a.py:5");
|
||||
expect(qualifiedReference({ ...base, language: "rust", path: "/p/src/a.rs", text: "", chain: [] })).toBe(
|
||||
"src/a.rs:5",
|
||||
);
|
||||
expect(qualifiedReference({ ...base, language: "java", path: "/p/src/A.java", text: "", chain: [] })).toBe(
|
||||
"src/A.java:5",
|
||||
);
|
||||
@@ -132,4 +179,11 @@ describe("copy reference", () => {
|
||||
expect(names(1, 2)).toEqual([]);
|
||||
expect(names(5, 0)).toEqual([]);
|
||||
});
|
||||
|
||||
it("only resolves a selected identifier or an identifier under the caret", () => {
|
||||
expect(referenceOffset(" load_scoped(x)", 2, 13)).toBe(2);
|
||||
expect(referenceOffset(" load_scoped(x)", 5, 5)).toBe(5);
|
||||
expect(referenceOffset(" load_scoped(x)", 0, 0)).toBeNull();
|
||||
expect(referenceOffset(" load_scoped(x)", 2, 16)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
import { setMethodLines, softWrapActive } from "../cm/prefs";
|
||||
import type { EditorSlot } from "./slot";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { logError } from "@/platform/errorLog";
|
||||
import { WorkspaceHistory } from "./workspaceHistory";
|
||||
|
||||
export type Banner = "binary" | "tooLarge" | "readOnly" | "library" | null;
|
||||
@@ -263,13 +264,15 @@ export class DocModel {
|
||||
},
|
||||
{
|
||||
applyRemote: (cs, grouped) => this.applyRemote(cs, grouped),
|
||||
onDiscarded: () =>
|
||||
onDiscarded: (reason) => {
|
||||
logError(`document ${this.path}: unsaved edits replaced by the file's content`, reason);
|
||||
manager.notify({
|
||||
level: "warning",
|
||||
title: "Editor resynchronised",
|
||||
body: tx`Some unsaved edits to ${this.path} could not be applied and were replaced by the file's current content.`,
|
||||
}),
|
||||
onError: (e) => console.warn(`document ${this.path}: sync error`, e),
|
||||
});
|
||||
},
|
||||
onError: (e) => logError(`document ${this.path}: sync error`, e),
|
||||
},
|
||||
manager.scheduler,
|
||||
);
|
||||
|
||||
@@ -226,7 +226,8 @@ describe("DocSync", () => {
|
||||
await server.processAll();
|
||||
await flushMicrotasks();
|
||||
expect(view.text.toString()).toBe("abc");
|
||||
expect(host.onDiscarded).toHaveBeenCalled();
|
||||
// The rejection reaches the host, which writes it to the log file.
|
||||
expect(host.onDiscarded).toHaveBeenCalledWith(expect.objectContaining({ kind: "permissionDenied" }));
|
||||
expect(sync.idle).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -35,6 +35,14 @@ describe("semanticRanges", () => {
|
||||
expect(semanticRanges(doc, input, false, "java")[0]?.className).toBe("cm-sem-property cm-sem-static");
|
||||
});
|
||||
|
||||
it("routes Python library symbols through its built-in name colour", () => {
|
||||
const input = { tokenTypes: ["function"], tokenModifiers: ["defaultLibrary"], data: [0, 0, 3, 0, 1] };
|
||||
expect(semanticRanges(doc, input, false, "python")[0]?.className).toBe(
|
||||
"cm-sem-function cm-sem-builtin cm-sem-python-builtin",
|
||||
);
|
||||
expect(semanticRanges(doc, input, false, "java")[0]?.className).toBe("cm-sem-function cm-sem-builtin");
|
||||
});
|
||||
|
||||
it("ignores type and modifier names that only exist on Object.prototype", () => {
|
||||
const input = {
|
||||
tokenTypes: ["method", "toString"],
|
||||
|
||||
@@ -402,9 +402,21 @@ const linkPlugin = ViewPlugin.fromClass(
|
||||
private probeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
constructor(readonly view: EditorView) {
|
||||
// Window-wide, so ⌘/Ctrl over an unfocused editor underlines too; released anywhere clears it.
|
||||
this.off = onNavigationModifier((held) => {
|
||||
const offModifier = onNavigationModifier((held) => {
|
||||
if (!held || this.last) this.show(held);
|
||||
});
|
||||
// IDEA's macOS "Force touch" shortcut: a force click navigates like ⌘-click
|
||||
// (the keyboard dispatcher keeps the system Look Up popover away).
|
||||
const onForceDown = (e: Event) => {
|
||||
const m = e as MouseEvent;
|
||||
if (m.metaKey || m.ctrlKey || m.altKey || m.shiftKey) return;
|
||||
if (navigateFromPointer(view, m)) m.preventDefault();
|
||||
};
|
||||
view.contentDOM.addEventListener("webkitmouseforcedown", onForceDown);
|
||||
this.off = () => {
|
||||
offModifier();
|
||||
view.contentDOM.removeEventListener("webkitmouseforcedown", onForceDown);
|
||||
};
|
||||
}
|
||||
destroy() {
|
||||
this.off();
|
||||
@@ -530,18 +542,24 @@ const linkPlugin = ViewPlugin.fromClass(
|
||||
}
|
||||
// IDEA parity: middle button navigates on a plain click, without the modifier.
|
||||
if (e.button === 1 ? e.metaKey || e.ctrlKey || e.altKey || e.shiftKey : !isNavigationGesture(e)) return false;
|
||||
const pos = view.posAtCoords({ x: e.clientX, y: e.clientY }, false);
|
||||
if (pos == null) return false;
|
||||
if (!navigateFromPointer(view, e)) return false;
|
||||
e.preventDefault();
|
||||
view.dispatch({ selection: EditorSelection.cursor(pos), effects: setLink.of(null) });
|
||||
view.focus();
|
||||
void gotoTargets(view, "definition", pos);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
/** Puts the caret under the pointer and goes to the declaration there, like a ⌘/Ctrl-click. */
|
||||
function navigateFromPointer(view: EditorView, e: MouseEvent): boolean {
|
||||
const pos = view.posAtCoords({ x: e.clientX, y: e.clientY }, false);
|
||||
if (pos == null) return false;
|
||||
view.dispatch({ selection: EditorSelection.cursor(pos), effects: setLink.of(null) });
|
||||
view.focus();
|
||||
void gotoTargets(view, "definition", pos);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Document highlights
|
||||
|
||||
|
||||
@@ -170,3 +170,32 @@ describe("mouse navigation", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("force click (macOS trackpad)", () => {
|
||||
function force(type: string, init: MouseEventInit = {}) {
|
||||
const event = new MouseEvent(type, { bubbles: true, cancelable: true, ...init });
|
||||
view.contentDOM.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
it("keeps the system Look Up popover away", () => {
|
||||
expect(force("webkitmouseforcewillbegin").defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it("goes to the declaration under the pointer, like ⌘-click", async () => {
|
||||
const event = force("webkitmouseforcedown");
|
||||
await vi.waitFor(() => expect(openFile).toHaveBeenCalledOnce());
|
||||
expect(lspGoto).toHaveBeenCalledExactlyOnceWith(1, { line: 0, col: 9 }, "definition");
|
||||
expect(view.state.selection.main.head).toBe(9);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it.each([{ metaKey: true }, { altKey: true }, { ctrlKey: true }, { shiftKey: true }])(
|
||||
"leaves a modified force click to the click gesture: %o",
|
||||
async (mods) => {
|
||||
force("webkitmouseforcedown", mods);
|
||||
await Promise.resolve();
|
||||
expect(lspGoto).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { ensureSyntaxTree, syntaxTree } from "@codemirror/language";
|
||||
import { findLanguageDescription, loadSupport } from "../core/cm/languages";
|
||||
import { pythonSyntaxRanges, pythonFallbackRanges, pythonSymbolKinds } from "./pythonHighlight";
|
||||
import { semanticRanges } from "./semantic";
|
||||
|
||||
async function parse(source: string) {
|
||||
const support = await loadSupport(findLanguageDescription("Python", "demo.py")!);
|
||||
const state = EditorState.create({ doc: source, extensions: [support!] });
|
||||
ensureSyntaxTree(state, state.doc.length, 1000);
|
||||
return state;
|
||||
}
|
||||
|
||||
describe("Python PyCharm categories", () => {
|
||||
it("distinguishes definitions, nested definitions, docstrings, self, bytes and keyword arguments", async () => {
|
||||
const state = await parse(`"""module doc"""
|
||||
def outer(x):
|
||||
"""function doc"""
|
||||
def nested(y):
|
||||
return y
|
||||
nested(x)
|
||||
class Foo:
|
||||
"""class doc"""
|
||||
def __init__(self):
|
||||
value = b"bytes\\n"
|
||||
self.run(whatever=1)
|
||||
print(self.__doc__)
|
||||
`);
|
||||
const ranges = pythonSyntaxRanges(syntaxTree(state), state.doc);
|
||||
const styles = ranges.map((r) => [state.doc.sliceString(r.from, r.to), r.className]);
|
||||
expect(styles).toContainEqual(["outer", "cm-sem-python-definition"]);
|
||||
expect(styles).toContainEqual(["nested", "cm-sem-python-nested"]);
|
||||
expect(styles).toContainEqual(["__init__", "cm-sem-python-predefined-definition"]);
|
||||
expect(styles).toContainEqual(["whatever", "cm-sem-python-keyword-arg"]);
|
||||
expect(styles).toContainEqual(["__doc__", "cm-sem-python-predefined-usage"]);
|
||||
expect(styles.filter(([, c]) => c === "cm-sem-python-docstring")).toHaveLength(3);
|
||||
expect(styles.filter(([, c]) => c === "cm-sem-self")).toHaveLength(3);
|
||||
expect(styles).toContainEqual(['b"bytes\\n"', "cm-sem-python-bytes"]);
|
||||
expect(styles).toContainEqual(["\\n", "cm-sem-python-escape"]);
|
||||
});
|
||||
|
||||
it("does not treat a locally shadowed builtin as a library symbol", async () => {
|
||||
const state = await parse("def f(print):\n print(1)\nlen([1])");
|
||||
const ranges = pythonFallbackRanges(syntaxTree(state), state.doc);
|
||||
const print = ranges.filter((r) => state.doc.sliceString(r.from, r.to) === "print");
|
||||
expect(print.map((r) => r.className)).toEqual(["cm-sem-parameter", "cm-sem-parameter"]);
|
||||
expect(ranges.find((r) => state.doc.sliceString(r.from, r.to) === "len")?.className).toContain(
|
||||
"cm-sem-python-builtin",
|
||||
);
|
||||
});
|
||||
|
||||
it("lets server classification override the local builtin guess", async () => {
|
||||
const state = await parse("print(1)");
|
||||
const ranges = semanticRanges(
|
||||
state.doc,
|
||||
{ tokenTypes: ["variable"], tokenModifiers: [], data: [0, 0, 5, 0, 0] },
|
||||
false,
|
||||
"python",
|
||||
syntaxTree(state),
|
||||
);
|
||||
expect(ranges.some((r) => r.className.includes("python-builtin"))).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps calls yellow when Pyrefly omits declaration modifiers", async () => {
|
||||
const state = await parse("def outer(x):\n outer(x)");
|
||||
const result = semanticRanges(
|
||||
state.doc,
|
||||
{
|
||||
tokenTypes: ["function"],
|
||||
tokenModifiers: [],
|
||||
data: [0, 4, 5, 0, 0, 1, 4, 5, 0, 0],
|
||||
},
|
||||
false,
|
||||
"python",
|
||||
syntaxTree(state),
|
||||
);
|
||||
expect(result.filter((r) => state.doc.sliceString(r.from, r.to) === "outer").map((r) => r.className)).toEqual([
|
||||
"cm-sem-python-definition",
|
||||
"cm-sem-function",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not misclassify ordinary strings, later expressions, bytes or f strings as docstrings", async () => {
|
||||
const state = await parse(
|
||||
'x = "ordinary"\n"later"\ndef f():\n b"bytes"\n "later"\ndef g():\n f"format {x}"',
|
||||
);
|
||||
expect(
|
||||
pythonSyntaxRanges(syntaxTree(state), state.doc).some((r) => r.className === "cm-sem-python-docstring"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("honours Pyrefly selfParameter modifiers without applying rainbow colours", async () => {
|
||||
const state = await parse("self arg");
|
||||
const result = semanticRanges(
|
||||
state.doc,
|
||||
{
|
||||
tokenTypes: ["parameter"],
|
||||
tokenModifiers: ["selfParameter"],
|
||||
data: [0, 0, 4, 0, 1, 0, 5, 3, 0, 0],
|
||||
},
|
||||
true,
|
||||
"python",
|
||||
);
|
||||
expect(result[0]?.className).toBe("cm-sem-parameter cm-sem-self");
|
||||
expect(result[1]?.className).toContain("cm-sem-rainbow-");
|
||||
});
|
||||
|
||||
it("classifies Python variables, attributes, properties, constants and type parameters", async () => {
|
||||
const state = await parse(`MAX_SIZE: Final = 10
|
||||
name = "x"
|
||||
T = TypeVar("T")
|
||||
type Alias[U] = list[U]
|
||||
|
||||
class Box[T]:
|
||||
count = 0
|
||||
ICON: Final = "box"
|
||||
|
||||
def __init__(self, value: T):
|
||||
self.value = value
|
||||
local = 1
|
||||
self.count
|
||||
self.value
|
||||
self.size
|
||||
|
||||
@property
|
||||
def size(self) -> T:
|
||||
return self.value
|
||||
|
||||
def read(self) -> T:
|
||||
return Box.count + Box.ICON
|
||||
`);
|
||||
const tree = syntaxTree(state);
|
||||
const kinds = pythonSymbolKinds(tree, state.doc);
|
||||
const kindOf = (name: string) =>
|
||||
[...kinds]
|
||||
.filter(([range]) => {
|
||||
const [from, to] = range.split(":").map(Number);
|
||||
return state.doc.sliceString(from!, to!) === name;
|
||||
})
|
||||
.map(([, kind]) => kind);
|
||||
expect(kindOf("local")).toEqual(["localVariable"]);
|
||||
expect(kindOf("name")).toEqual(["globalVariable"]);
|
||||
expect(kindOf("MAX_SIZE")).toEqual(["constant"]);
|
||||
expect(kindOf("value")).toContain("instanceAttribute");
|
||||
expect(kindOf("count")).toContain("classAttribute");
|
||||
expect(kindOf("ICON")).toContain("constant");
|
||||
expect(kindOf("size")).toContain("property");
|
||||
expect(kindOf("T")).toEqual(expect.arrayContaining(["typeParameter"]));
|
||||
expect(kindOf("U")).toEqual(["typeParameter", "typeParameter"]);
|
||||
|
||||
const syntax = pythonSyntaxRanges(tree, state.doc, kinds);
|
||||
const syntaxClass = (name: string) =>
|
||||
syntax.filter((range) => state.doc.sliceString(range.from, range.to) === name).map((range) => range.className);
|
||||
expect(syntaxClass("size")).toContain("cm-sem-property cm-sem-python-property");
|
||||
expect(syntaxClass("T")).toContain("cm-sem-typeParameter cm-sem-python-type-parameter");
|
||||
});
|
||||
|
||||
it("keeps Pyrefly tokens but applies the finer Python category", async () => {
|
||||
const state = await parse("MAX_SIZE: Final = 10\ndef f(x):\n local = x\n return local");
|
||||
const result = semanticRanges(
|
||||
state.doc,
|
||||
{
|
||||
tokenTypes: ["variable"],
|
||||
tokenModifiers: [],
|
||||
data: [0, 0, 8, 0, 0, 2, 4, 5, 0, 1, 3, 11, 5, 0, 2],
|
||||
},
|
||||
true,
|
||||
"python",
|
||||
syntaxTree(state),
|
||||
);
|
||||
const classOf = (name: string) =>
|
||||
result.filter((range) => state.doc.sliceString(range.from, range.to) === name).map((range) => range.className);
|
||||
expect(classOf("MAX_SIZE")).toEqual(["cm-sem-constant cm-sem-python-constant"]);
|
||||
expect(classOf("local")[0]).toContain("cm-sem-variable cm-sem-python-local-variable cm-sem-rainbow-");
|
||||
expect(classOf("local")[1]).toContain("cm-sem-python-local-variable");
|
||||
expect(classOf("MAX_SIZE")[0]).not.toContain("cm-sem-rainbow-");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,513 @@
|
||||
import type { SyntaxNode, Tree } from "@lezer/common";
|
||||
import type { Text } from "@codemirror/state";
|
||||
|
||||
interface PythonRange {
|
||||
from: number;
|
||||
to: number;
|
||||
className: string;
|
||||
source: "pythonSyntax" | "pythonFallback";
|
||||
}
|
||||
|
||||
export type PythonSymbolKind =
|
||||
| "function"
|
||||
| "type"
|
||||
| "parameter"
|
||||
| "localVariable"
|
||||
| "globalVariable"
|
||||
| "instanceAttribute"
|
||||
| "classAttribute"
|
||||
| "constant"
|
||||
| "property"
|
||||
| "typeParameter"
|
||||
| "builtin";
|
||||
|
||||
const PYTHON_CLASSES: Record<PythonSymbolKind, string> = {
|
||||
function: "cm-sem-function",
|
||||
type: "cm-sem-type",
|
||||
parameter: "cm-sem-parameter",
|
||||
localVariable: "cm-sem-variable cm-sem-python-local-variable",
|
||||
globalVariable: "cm-sem-global cm-sem-python-global-variable",
|
||||
instanceAttribute: "cm-sem-property cm-sem-python-instance-attribute",
|
||||
classAttribute: "cm-sem-property cm-sem-python-class-attribute",
|
||||
constant: "cm-sem-constant cm-sem-python-constant",
|
||||
property: "cm-sem-property cm-sem-python-property",
|
||||
typeParameter: "cm-sem-typeParameter cm-sem-python-type-parameter",
|
||||
builtin: "cm-sem-builtin cm-sem-python-builtin",
|
||||
};
|
||||
|
||||
export const pythonSymbolClasses = (kind: PythonSymbolKind) => PYTHON_CLASSES[kind];
|
||||
|
||||
const BUILTIN_TYPES = new Set([
|
||||
"int",
|
||||
"str",
|
||||
"bool",
|
||||
"bytes",
|
||||
"float",
|
||||
"complex",
|
||||
"list",
|
||||
"dict",
|
||||
"tuple",
|
||||
"set",
|
||||
"frozenset",
|
||||
"object",
|
||||
"type",
|
||||
"None",
|
||||
]);
|
||||
|
||||
function hasAncestor(node: SyntaxNode, name: string): boolean {
|
||||
for (let parent = node.parent; parent; parent = parent.parent) if (parent.name === name) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function enclosingFunction(node: SyntaxNode | null): SyntaxNode | null {
|
||||
for (; node; node = node.parent) if (node.name === "FunctionDefinition") return node;
|
||||
return null;
|
||||
}
|
||||
|
||||
const FUNCTION_SCOPES = new Set(["FunctionDefinition", "LambdaExpression"]);
|
||||
const COMPREHENSION_SCOPES = new Set([
|
||||
"ArrayComprehensionExpression",
|
||||
"DictionaryComprehensionExpression",
|
||||
"SetComprehensionExpression",
|
||||
]);
|
||||
const SCOPE_NODES = new Set([
|
||||
"FunctionDefinition",
|
||||
"LambdaExpression",
|
||||
"ClassDefinition",
|
||||
"TypeDefinition",
|
||||
"Script",
|
||||
...COMPREHENSION_SCOPES,
|
||||
]);
|
||||
const PATTERN_CONTAINERS = new Set([
|
||||
"TupleExpression",
|
||||
"ArrayExpression",
|
||||
"ParenthesizedExpression",
|
||||
"SequencePattern",
|
||||
"PatternArgList",
|
||||
"StarPattern",
|
||||
]);
|
||||
const TYPE_VAR_CALLS = new Set(["TypeVar", "ParamSpec", "TypeVarTuple"]);
|
||||
const KIND_PRIORITY: Record<PythonSymbolKind, number> = {
|
||||
globalVariable: 70,
|
||||
typeParameter: 90,
|
||||
parameter: 80,
|
||||
property: 80,
|
||||
constant: 70,
|
||||
classAttribute: 60,
|
||||
localVariable: 50,
|
||||
function: 40,
|
||||
type: 40,
|
||||
instanceAttribute: 40,
|
||||
builtin: 30,
|
||||
};
|
||||
|
||||
function scope(node: SyntaxNode | null): SyntaxNode | null {
|
||||
for (; node; node = node.parent) if (SCOPE_NODES.has(node.name)) return node;
|
||||
return null;
|
||||
}
|
||||
|
||||
function enclosingClass(node: SyntaxNode | null): SyntaxNode | null {
|
||||
for (; node; node = node.parent) if (node.name === "ClassDefinition") return node;
|
||||
return null;
|
||||
}
|
||||
|
||||
function directName(node: SyntaxNode | null): SyntaxNode | null {
|
||||
for (let child = node?.firstChild; child; child = child.nextSibling) if (child.name === "VariableName") return child;
|
||||
return null;
|
||||
}
|
||||
|
||||
function isConstantName(name: string): boolean {
|
||||
return /^_*[A-Z][A-Z0-9_]*$/.test(name);
|
||||
}
|
||||
|
||||
function hasFinalAnnotation(node: SyntaxNode, doc: Text): boolean {
|
||||
for (let child = node.firstChild; child; child = child.nextSibling) {
|
||||
if (
|
||||
(child.name === "VariableName" || child.name === "PropertyName") &&
|
||||
doc.sliceString(child.from, child.to) === "Final"
|
||||
)
|
||||
return true;
|
||||
if (hasFinalAnnotation(child, doc)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function callName(node: SyntaxNode | null, doc: Text): string | undefined {
|
||||
if (node?.name !== "CallExpression") return undefined;
|
||||
const callee = node.firstChild;
|
||||
if (callee?.name === "VariableName") return doc.sliceString(callee.from, callee.to);
|
||||
if (callee?.name === "MemberExpression") {
|
||||
const property = callee.getChild("PropertyName");
|
||||
if (property) return doc.sliceString(property.from, property.to);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function assignmentRight(node: SyntaxNode): SyntaxNode | null {
|
||||
const assign = node.getChild("AssignOp");
|
||||
return assign?.nextSibling ?? null;
|
||||
}
|
||||
|
||||
function assignmentAnnotation(node: SyntaxNode): SyntaxNode | null {
|
||||
return node.getChild("TypeDef");
|
||||
}
|
||||
|
||||
function collectPattern(node: SyntaxNode, out: SyntaxNode[]) {
|
||||
if (node.name === "VariableName" || node.name === "MemberExpression") {
|
||||
out.push(node);
|
||||
return;
|
||||
}
|
||||
if (!PATTERN_CONTAINERS.has(node.name)) return;
|
||||
for (let child = node.firstChild; child; child = child.nextSibling) collectPattern(child, out);
|
||||
}
|
||||
|
||||
function assignmentTargets(node: SyntaxNode, stop: string): SyntaxNode[] {
|
||||
const out: SyntaxNode[] = [];
|
||||
for (let child = node.firstChild; child; child = child.nextSibling) {
|
||||
if (child.name === stop) break;
|
||||
if (child.name === "TypeDef") continue;
|
||||
collectPattern(child, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function forTargets(node: SyntaxNode): SyntaxNode[] {
|
||||
const out: SyntaxNode[] = [];
|
||||
let collecting = false;
|
||||
for (let child = node.firstChild; child; child = child.nextSibling) {
|
||||
if (child.name === "for") {
|
||||
collecting = true;
|
||||
continue;
|
||||
}
|
||||
if (!collecting) continue;
|
||||
if (child.name === "in") break;
|
||||
collectPattern(child, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function aliasTargets(node: SyntaxNode): SyntaxNode[] {
|
||||
const out: SyntaxNode[] = [];
|
||||
for (let child = node.firstChild; child; child = child.nextSibling) {
|
||||
if (child.name === "as") {
|
||||
const target = child.nextSibling;
|
||||
if (target) collectPattern(target, out);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isPropertyFunction(node: SyntaxNode, name: string, doc: Text): boolean {
|
||||
const decorated = node.parent;
|
||||
if (decorated?.name !== "DecoratedStatement") return false;
|
||||
for (let child = decorated.firstChild; child; child = child.nextSibling) {
|
||||
if (child.name !== "Decorator") continue;
|
||||
const decorator = doc.sliceString(child.from + 1, child.to).trim();
|
||||
if (decorator === "property" || decorator === "cached_property" || decorator.endsWith(".cached_property"))
|
||||
return true;
|
||||
const dot = decorator.lastIndexOf(".");
|
||||
if (
|
||||
dot > 0 &&
|
||||
decorator.slice(0, dot) === name &&
|
||||
["setter", "getter", "deleter"].includes(decorator.slice(dot + 1))
|
||||
)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Classifies every Python identifier from scope bindings; the server only distinguishes some of them. */
|
||||
export function pythonSymbolKinds(tree: Tree, doc: Text): Map<string, PythonSymbolKind> {
|
||||
const bindings = new Map<string, Map<string, PythonSymbolKind>>();
|
||||
const classByName = new Map<string, SyntaxNode>();
|
||||
const classData = new Map<string, Set<string>>();
|
||||
const propertyNames = new Set<string>();
|
||||
const key = (node: SyntaxNode) => `${node.name}:${node.from}`;
|
||||
const text = (node: SyntaxNode) => doc.sliceString(node.from, node.to);
|
||||
const dataFor = (node: SyntaxNode) => {
|
||||
const scopeKey = key(node);
|
||||
let data = classData.get(scopeKey);
|
||||
if (!data) classData.set(scopeKey, (data = new Set()));
|
||||
return data;
|
||||
};
|
||||
const bind = (owner: SyntaxNode | null, name: string, kind: PythonSymbolKind) => {
|
||||
if (!owner) return;
|
||||
let names = bindings.get(key(owner));
|
||||
if (!names) bindings.set(key(owner), (names = new Map()));
|
||||
const previous = names.get(name);
|
||||
if (previous && KIND_PRIORITY[previous] >= KIND_PRIORITY[kind]) return;
|
||||
names.set(name, kind);
|
||||
};
|
||||
|
||||
tree.iterate({
|
||||
enter(cursor) {
|
||||
const node = cursor.node;
|
||||
if (node.name === "ClassDefinition") {
|
||||
const classNode = directName(node);
|
||||
if (classNode) {
|
||||
bind(scope(node.parent), text(classNode), "type");
|
||||
classByName.set(text(classNode), node);
|
||||
dataFor(node);
|
||||
}
|
||||
} else if (node.name === "TypeDefinition") {
|
||||
const typeNode = directName(node);
|
||||
if (typeNode) bind(scope(node.parent), text(typeNode), "type");
|
||||
} else if (node.name === "FunctionDefinition") {
|
||||
const functionNode = directName(node);
|
||||
if (!functionNode) return;
|
||||
const functionName = text(functionNode);
|
||||
const property = isPropertyFunction(node, functionName, doc);
|
||||
bind(scope(node.parent), functionName, property ? "property" : "function");
|
||||
if (property) {
|
||||
propertyNames.add(functionName);
|
||||
}
|
||||
} else if (node.name === "ParamList") {
|
||||
const owner = scope(node);
|
||||
for (let child = node.firstChild; child; child = child.nextSibling)
|
||||
if (child.name === "VariableName") bind(owner, text(child), "parameter");
|
||||
} else if (node.name === "TypeParam") {
|
||||
const param = directName(node);
|
||||
if (param) bind(scope(node), text(param), "typeParameter");
|
||||
} else if (node.name === "ScopeStatement" && node.firstChild?.name === "global") {
|
||||
const owner = scope(node);
|
||||
for (let child: SyntaxNode | null = node.firstChild; child; child = child.nextSibling)
|
||||
if (child.name === "VariableName") bind(owner, text(child), "globalVariable");
|
||||
}
|
||||
if (node.name === "CapturePattern") {
|
||||
const capture = directName(node);
|
||||
if (capture) {
|
||||
const owner = scope(node);
|
||||
const kind =
|
||||
owner?.name === "ClassDefinition"
|
||||
? "classAttribute"
|
||||
: owner?.name === "Script"
|
||||
? "globalVariable"
|
||||
: "localVariable";
|
||||
bind(owner, text(capture), kind);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const assignmentKind = (owner: SyntaxNode | null, name: string, annotation: SyntaxNode | null): PythonSymbolKind => {
|
||||
if (annotation && hasFinalAnnotation(annotation, doc)) return "constant";
|
||||
if (isConstantName(name)) return "constant";
|
||||
if (owner?.name === "Script") return "globalVariable";
|
||||
if (owner?.name === "ClassDefinition") return "classAttribute";
|
||||
return "localVariable";
|
||||
};
|
||||
|
||||
tree.iterate({
|
||||
enter(cursor) {
|
||||
const node = cursor.node;
|
||||
if (node.name === "AssignStatement" || node.name === "UpdateStatement") {
|
||||
const stop = node.name === "AssignStatement" ? "AssignOp" : "UpdateOp";
|
||||
const owner = scope(node);
|
||||
const targets = assignmentTargets(node, stop);
|
||||
const annotation = node.name === "AssignStatement" ? assignmentAnnotation(node) : null;
|
||||
const called = node.name === "AssignStatement" ? callName(assignmentRight(node), doc) : undefined;
|
||||
const typeParameter = called !== undefined && TYPE_VAR_CALLS.has(called);
|
||||
for (const target of targets) {
|
||||
if (target.name === "MemberExpression") {
|
||||
const object = target.firstChild;
|
||||
const property = target.getChild("PropertyName");
|
||||
if (object?.name === "VariableName" && property && text(object) === "self") {
|
||||
const ownerClass = enclosingClass(node);
|
||||
if (ownerClass) dataFor(ownerClass).add(text(property));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const name = text(target);
|
||||
bind(owner, name, typeParameter ? "typeParameter" : assignmentKind(owner, name, annotation));
|
||||
}
|
||||
} else if (node.name === "NamedExpression") {
|
||||
const target = node.firstChild;
|
||||
if (target?.name === "VariableName")
|
||||
bind(scope(node), text(target), assignmentKind(scope(node), text(target), null));
|
||||
} else if (node.name === "ForStatement") {
|
||||
const owner = scope(node);
|
||||
for (const target of forTargets(node))
|
||||
if (target.name === "VariableName") bind(owner, text(target), assignmentKind(owner, text(target), null));
|
||||
} else if (COMPREHENSION_SCOPES.has(node.name)) {
|
||||
const owner = scope(node);
|
||||
for (const target of forTargets(node))
|
||||
if (target.name === "VariableName") bind(owner, text(target), "localVariable");
|
||||
} else if (node.name === "WithStatement" || node.name === "TryStatement") {
|
||||
const owner = scope(node);
|
||||
for (const target of aliasTargets(node))
|
||||
if (target.name === "VariableName") bind(owner, text(target), assignmentKind(owner, text(target), null));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const resolve = (
|
||||
node: SyntaxNode,
|
||||
name: string,
|
||||
start: SyntaxNode | null = scope(node),
|
||||
): PythonSymbolKind | undefined => {
|
||||
let crossedFunction = false;
|
||||
for (let current = start; current; current = scope(current.parent)) {
|
||||
if (FUNCTION_SCOPES.has(current.name)) crossedFunction = true;
|
||||
const kind = bindings.get(key(current))?.get(name);
|
||||
if (!kind) continue;
|
||||
if (crossedFunction && current.name === "ClassDefinition" && kind !== "typeParameter") continue;
|
||||
return kind;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const classMember = (node: SyntaxNode | null, name: string) =>
|
||||
node ? bindings.get(key(node))?.get(name) : undefined;
|
||||
const memberKind = (node: SyntaxNode, name: string): PythonSymbolKind | undefined => {
|
||||
const member = node.parent;
|
||||
if (member?.name !== "MemberExpression") return undefined;
|
||||
const object = member.firstChild;
|
||||
if (!object) return undefined;
|
||||
const objectName = object.name === "VariableName" ? text(object) : undefined;
|
||||
const currentClass = enclosingClass(node);
|
||||
const namedClass = objectName ? classByName.get(objectName) : undefined;
|
||||
const owner = objectName === "self" || objectName === "cls" ? currentClass : namedClass;
|
||||
const bound = classMember(owner ?? null, name);
|
||||
const call = member.parent?.name === "CallExpression" && member.parent.firstChild?.from === member.from;
|
||||
if (call) return bound === "property" || bound === "constant" ? bound : undefined;
|
||||
if (objectName === "self" && currentClass) {
|
||||
if (bound === "property") return "property";
|
||||
if (owner && dataFor(owner).has(name)) return "instanceAttribute";
|
||||
if (bound) return bound;
|
||||
if (propertyNames.has(name)) return "property";
|
||||
if (isConstantName(name)) return "constant";
|
||||
return "instanceAttribute";
|
||||
}
|
||||
if (objectName === "cls" && currentClass) {
|
||||
if (bound) return bound;
|
||||
if (isConstantName(name)) return "constant";
|
||||
return "classAttribute";
|
||||
}
|
||||
if (namedClass) {
|
||||
if (bound) return bound;
|
||||
if (isConstantName(name)) return "constant";
|
||||
return "classAttribute";
|
||||
}
|
||||
if (propertyNames.has(name)) return "property";
|
||||
if (isConstantName(name)) return "constant";
|
||||
return "instanceAttribute";
|
||||
};
|
||||
const kindFor = (node: SyntaxNode): PythonSymbolKind | undefined => {
|
||||
const name = text(node);
|
||||
if (node.name === "PropertyName") return memberKind(node, name);
|
||||
const start = node.parent?.name === "FunctionDefinition" ? scope(node.parent.parent) : scope(node);
|
||||
const resolved = resolve(node, name, start);
|
||||
if (resolved) return resolved;
|
||||
if (isConstantName(name) && !BUILTIN_NAMES.has(name)) return "constant";
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const out = new Map<string, PythonSymbolKind>();
|
||||
tree.iterate({
|
||||
enter(cursor) {
|
||||
const node = cursor.node;
|
||||
if (node.name !== "VariableName" && node.name !== "PropertyName") return;
|
||||
const kind = kindFor(node);
|
||||
if (kind) out.set(`${node.from}:${node.to}`, kind);
|
||||
},
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Python categories absent from LSP tokens, derived from the editor's existing parse tree. */
|
||||
export function pythonSyntaxRanges(
|
||||
tree: Tree,
|
||||
doc: Text,
|
||||
symbols: Map<string, PythonSymbolKind> = pythonSymbolKinds(tree, doc),
|
||||
): PythonRange[] {
|
||||
const ranges: PythonRange[] = [];
|
||||
tree.iterate({
|
||||
enter(cursor) {
|
||||
const node = cursor.node;
|
||||
if (!["VariableName", "PropertyName", "String", "Escape", "(", ")", "[", "]", "{", "}"].includes(node.name))
|
||||
return;
|
||||
const text = doc.sliceString(node.from, node.to);
|
||||
const symbol = symbols.get(`${node.from}:${node.to}`);
|
||||
let style: string | undefined;
|
||||
let className: string | undefined;
|
||||
if (["(", ")"].includes(node.name)) style = "python-parentheses";
|
||||
else if (["[", "]"].includes(node.name)) style = "python-brackets";
|
||||
else if (["{", "}"].includes(node.name)) style = "python-braces";
|
||||
else if (node.name === "VariableName") {
|
||||
if (symbol === "typeParameter") className = pythonSymbolClasses(symbol);
|
||||
else if (node.parent?.name === "FunctionDefinition") {
|
||||
if (symbol === "property") className = pythonSymbolClasses(symbol);
|
||||
else
|
||||
style = /^__.*__$/.test(text)
|
||||
? "python-predefined-definition"
|
||||
: enclosingFunction(node.parent.parent)
|
||||
? "python-nested"
|
||||
: "python-definition";
|
||||
} else if (hasAncestor(node, "TypeDef") && !BUILTIN_TYPES.has(text)) style = "python-annotation";
|
||||
else if ((text === "self" || text === "cls") && enclosingFunction(node.parent)) style = "self";
|
||||
else if (node.parent?.name === "ArgList" && node.nextSibling?.name === "AssignOp") style = "python-keyword-arg";
|
||||
} else if (node.name === "PropertyName" && /^__.*__$/.test(text)) style = "python-predefined-usage";
|
||||
else if (
|
||||
node.name === "PropertyName" &&
|
||||
node.parent?.name === "MemberExpression" &&
|
||||
node.parent.parent?.name === "CallExpression"
|
||||
)
|
||||
style = "python-method-call";
|
||||
else if (node.name === "String") {
|
||||
const statement = node.parent;
|
||||
const body = statement?.parent;
|
||||
let first = body?.firstChild;
|
||||
while (first && (first.name === ":" || first.name === "Comment")) first = first.nextSibling;
|
||||
const docstring =
|
||||
statement?.name === "ExpressionStatement" &&
|
||||
statement.firstChild?.from === node.from &&
|
||||
statement.firstChild?.to === node.to &&
|
||||
first?.from === statement.from &&
|
||||
(body?.name === "Script" ||
|
||||
(body?.name === "Body" && ["FunctionDefinition", "ClassDefinition"].includes(body.parent?.name ?? "")));
|
||||
if (docstring && !/^[bf]/i.test(text)) style = "python-docstring";
|
||||
else if (/^[rub]*b[rub]*['"]/i.test(text)) style = "python-bytes";
|
||||
} else if (node.name === "Escape") style = "python-escape";
|
||||
if (style) className = `cm-sem-${style}`;
|
||||
if (className) ranges.push({ from: node.from, to: node.to, className, source: "pythonSyntax" });
|
||||
if (style === "python-docstring") return false;
|
||||
},
|
||||
});
|
||||
return ranges;
|
||||
}
|
||||
|
||||
const BUILTIN_NAMES = new Set(
|
||||
"abs aiter all anext any ascii bin bool breakpoint bytearray bytes callable chr classmethod compile complex delattr dict dir divmod enumerate eval exec filter float format frozenset getattr globals hasattr hash help hex id input int isinstance issubclass iter len list locals map max memoryview min next object oct open ord pow print property range repr reversed round set setattr slice sorted staticmethod str sum super tuple type vars zip __import__ BaseException Exception ArithmeticError AssertionError AttributeError EOFError ImportError IndexError KeyError LookupError NameError NotImplementedError OSError OverflowError RuntimeError StopIteration SyntaxError TypeError UnboundLocalError ValueError ZeroDivisionError".split(
|
||||
" ",
|
||||
),
|
||||
);
|
||||
|
||||
/** Fast local guesses while LSP is pending. Server tokens take precedence over these. */
|
||||
export function pythonFallbackRanges(
|
||||
tree: Tree,
|
||||
doc: Text,
|
||||
rainbow?: (name: string) => string,
|
||||
symbols: Map<string, PythonSymbolKind> = pythonSymbolKinds(tree, doc),
|
||||
): PythonRange[] {
|
||||
const ranges: PythonRange[] = [];
|
||||
tree.iterate({
|
||||
enter(cursor) {
|
||||
const node = cursor.node;
|
||||
if (node.name !== "VariableName" && node.name !== "PropertyName") return;
|
||||
const name = doc.sliceString(node.from, node.to);
|
||||
const kind =
|
||||
symbols.get(`${node.from}:${node.to}`) ??
|
||||
(node.name === "VariableName" && BUILTIN_NAMES.has(name) ? "builtin" : undefined);
|
||||
if (!kind) return;
|
||||
const local = kind === "parameter" || kind === "localVariable";
|
||||
ranges.push({
|
||||
from: node.from,
|
||||
to: node.to,
|
||||
className:
|
||||
pythonSymbolClasses(kind) +
|
||||
(local && rainbow && name !== "self" && name !== "cls" ? ` ${rainbow(name)}` : ""),
|
||||
source: "pythonFallback",
|
||||
});
|
||||
},
|
||||
});
|
||||
return ranges;
|
||||
}
|
||||
+150
-13
@@ -9,6 +9,15 @@
|
||||
* `cm-sem-rainbow-<n>`, `n` derived from the name, so one name keeps one
|
||||
* colour (`--syn-rainbow-<n>`) throughout the file.
|
||||
*/
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
import type { Tree } from "@lezer/common";
|
||||
import {
|
||||
pythonSyntaxRanges,
|
||||
pythonFallbackRanges,
|
||||
pythonSymbolClasses,
|
||||
pythonSymbolKinds,
|
||||
type PythonSymbolKind,
|
||||
} from "./pythonHighlight";
|
||||
import { Prec, StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
|
||||
import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from "@codemirror/view";
|
||||
import { commands, type SemanticTokens } from "@/platform/ipc";
|
||||
@@ -57,6 +66,11 @@ const MODIFIER_CLASS: Record<string, string> = {
|
||||
definition: "declaration",
|
||||
defaultLibrary: "builtin",
|
||||
global: "global",
|
||||
selfParameter: "self",
|
||||
byteString: "python-bytes",
|
||||
escapeSequence: "python-escape",
|
||||
formatSpecifier: "python-format",
|
||||
formatPlaceholder: "python-format",
|
||||
};
|
||||
|
||||
/** Number of `--syn-rainbow-<n>` colours (IDEA's five semantic highlighting colours). */
|
||||
@@ -75,15 +89,60 @@ export function rainbowIndex(name: string): number {
|
||||
/** Locals and parameters only: no fields, statics, constants, library or `self` names. */
|
||||
const RAINBOW_TYPES = new Set(["variable", "parameter"]);
|
||||
const NOT_LOCAL = new Set(["static", "constant", "defaultLibrary", "global", "classScope", "fileScope"]);
|
||||
const PYTHON_VARIABLE_KINDS = new Set<PythonSymbolKind>([
|
||||
"localVariable",
|
||||
"globalVariable",
|
||||
"instanceAttribute",
|
||||
"classAttribute",
|
||||
"constant",
|
||||
"property",
|
||||
"typeParameter",
|
||||
]);
|
||||
const PYTHON_SYMBOL_BASES = new Set([
|
||||
"cm-sem-variable",
|
||||
"cm-sem-global",
|
||||
"cm-sem-property",
|
||||
"cm-sem-constant",
|
||||
"cm-sem-type",
|
||||
"cm-sem-typeParameter",
|
||||
"cm-sem-builtin",
|
||||
]);
|
||||
|
||||
export interface SemanticRange {
|
||||
from: number;
|
||||
to: number;
|
||||
className: string;
|
||||
source?: "pythonSyntax" | "pythonFallback";
|
||||
}
|
||||
|
||||
function rangeCoverage(ranges: Array<{ from: number; to: number }>) {
|
||||
ranges.sort((a, b) => a.from - b.from || a.to - b.to);
|
||||
const ends: number[] = [];
|
||||
for (const r of ranges) ends.push(Math.max(ends.at(-1) ?? 0, r.to));
|
||||
return (from: number, to: number) => {
|
||||
let low = 0,
|
||||
high = ranges.length;
|
||||
while (low < high) {
|
||||
const mid = (low + high) >>> 1;
|
||||
if (ranges[mid]!.from <= from) low = mid + 1;
|
||||
else high = mid;
|
||||
}
|
||||
return low > 0 && ends[low - 1]! >= to;
|
||||
};
|
||||
}
|
||||
|
||||
/** Decodes absolute `[line, col, len, type, modifiers]` quintuples into styled ranges. */
|
||||
export function semanticRanges(doc: Text, tokens: SemanticTokens, rainbow = false, language?: string): SemanticRange[] {
|
||||
export function semanticRanges(
|
||||
doc: Text,
|
||||
tokens: SemanticTokens,
|
||||
rainbow = false,
|
||||
language?: string,
|
||||
tree?: Tree,
|
||||
): SemanticRange[] {
|
||||
const symbols = language === "python" && tree ? pythonSymbolKinds(tree, doc) : undefined;
|
||||
const syntax = language === "python" && tree ? pythonSyntaxRanges(tree, doc, symbols) : [];
|
||||
const covered = rangeCoverage(syntax);
|
||||
const serverRanges: Array<{ from: number; to: number }> = [];
|
||||
const out: SemanticRange[] = [];
|
||||
const { data, tokenTypes, tokenModifiers } = tokens;
|
||||
// Servers with a `local` modifier (TypeScript) tell module-level variables apart.
|
||||
@@ -95,7 +154,12 @@ export function semanticRanges(doc: Text, tokens: SemanticTokens, rainbow = fals
|
||||
typeIdx = data[i + 3]!,
|
||||
mods = data[i + 4]!;
|
||||
if (line >= doc.lines || len <= 0) continue;
|
||||
const l = doc.line(line + 1);
|
||||
const from = l.from + Math.min(col, l.length);
|
||||
const to = Math.min(from + len, l.to);
|
||||
if (from >= to) continue;
|
||||
const type = tokenTypes[typeIdx] ?? "";
|
||||
const pythonKind = symbols?.get(`${from}:${to}`);
|
||||
const names: string[] = [];
|
||||
for (let b = 0; b < tokenModifiers.length; b++) if (mods & (1 << b)) names.push(tokenModifiers[b]!);
|
||||
const classes: string[] = [];
|
||||
@@ -109,27 +173,49 @@ export function semanticRanges(doc: Text, tokens: SemanticTokens, rainbow = fals
|
||||
// `hasOwn`: servers send names like `constructor` (jdtls) that `Object.prototype` also has.
|
||||
for (const n of names) if (Object.hasOwn(MODIFIER_CLASS, n)) classes.push(`cm-sem-${MODIFIER_CLASS[n]}`);
|
||||
if (language === "python" && names.includes("static")) classes.push("cm-sem-python-static");
|
||||
if (language === "python" && type === "method") classes.push("cm-sem-python-method-call");
|
||||
if (language === "python" && names.includes("defaultLibrary")) classes.push("cm-sem-python-builtin");
|
||||
if (pythonKind && PYTHON_VARIABLE_KINDS.has(pythonKind)) {
|
||||
for (let j = classes.length - 1; j >= 0; j--) if (PYTHON_SYMBOL_BASES.has(classes[j]!)) classes.splice(j, 1);
|
||||
classes.push(...pythonSymbolClasses(pythonKind).split(" "));
|
||||
}
|
||||
const local =
|
||||
rainbow &&
|
||||
!constant &&
|
||||
RAINBOW_TYPES.has(type) &&
|
||||
!names.includes("selfParameter") &&
|
||||
!names.some((n) => NOT_LOCAL.has(n)) &&
|
||||
(type === "parameter" || !marksLocals || names.includes("local"));
|
||||
(language === "python"
|
||||
? pythonKind
|
||||
? pythonKind === "localVariable" || pythonKind === "parameter"
|
||||
: type === "parameter"
|
||||
: RAINBOW_TYPES.has(type) && (type === "parameter" || !marksLocals || names.includes("local")));
|
||||
serverRanges.push({ from, to });
|
||||
if (!classes.length && !local) continue;
|
||||
const l = doc.line(line + 1);
|
||||
const from = l.from + Math.min(col, l.length);
|
||||
const to = Math.min(from + len, l.to);
|
||||
if (from >= to) continue;
|
||||
if (covered(from, to)) continue;
|
||||
if (local) classes.push(`cm-sem-rainbow-${rainbowIndex(doc.sliceString(from, to))}`);
|
||||
out.push({ from, to, className: classes.join(" ") });
|
||||
}
|
||||
if (language === "python" && tree) {
|
||||
const serverCovered = rangeCoverage(serverRanges);
|
||||
out.push(
|
||||
...pythonFallbackRanges(
|
||||
tree,
|
||||
doc,
|
||||
rainbow ? (name) => `cm-sem-rainbow-${rainbowIndex(name)}` : undefined,
|
||||
symbols,
|
||||
).filter((r) => !covered(r.from, r.to) && !serverCovered(r.from, r.to)),
|
||||
);
|
||||
}
|
||||
out.push(...syntax);
|
||||
return out.sort((a, b) => a.from - b.from || a.to - b.to);
|
||||
}
|
||||
|
||||
const markCache = new Map<string, Decoration>();
|
||||
const markFor = (cls: string) => {
|
||||
let m = markCache.get(cls);
|
||||
if (!m) markCache.set(cls, (m = Decoration.mark({ class: cls })));
|
||||
const markFor = (cls: string, source?: "pythonSyntax" | "pythonFallback", symbol?: string) => {
|
||||
if (symbol !== undefined) return Decoration.mark({ class: cls, symbol });
|
||||
const key = `${source ?? "semantic"}:${cls}`;
|
||||
let m = markCache.get(key);
|
||||
if (!m) markCache.set(key, (m = Decoration.mark({ class: cls, pythonSyntax: source !== undefined })));
|
||||
return m;
|
||||
};
|
||||
|
||||
@@ -151,12 +237,24 @@ const semanticPlugin = ViewPlugin.fromClass(
|
||||
/** Last answer, re-decorated without a request when Semantic highlighting is toggled. */
|
||||
private last: { doc: Text; tokens: SemanticTokens | null } | null = null;
|
||||
private destroyed = false;
|
||||
private tree: Tree | null = null;
|
||||
constructor(private view: EditorView) {
|
||||
queueMicrotask(() => {
|
||||
if (!this.destroyed) this.decorate(this.view.state.doc, null);
|
||||
});
|
||||
this.refresh();
|
||||
this.offReady = onLspRefresh("semanticTokens", () => this.refresh());
|
||||
}
|
||||
update(u: ViewUpdate) {
|
||||
if (u.docChanged) this.refresh();
|
||||
const tree = syntaxTree(u.state);
|
||||
if (u.docChanged || tree !== this.tree) {
|
||||
this.tree = tree;
|
||||
this.refresh();
|
||||
const doc = u.state.doc;
|
||||
queueMicrotask(() => {
|
||||
if (!this.destroyed && this.view.state.doc === doc) this.refreshSyntax(doc);
|
||||
});
|
||||
}
|
||||
const rainbow = editorPrefs().semanticHighlighting;
|
||||
if (rainbow === this.rainbow) return;
|
||||
this.rainbow = rainbow;
|
||||
@@ -168,13 +266,52 @@ const semanticPlugin = ViewPlugin.fromClass(
|
||||
});
|
||||
else this.refresh();
|
||||
}
|
||||
refreshSyntax(doc: Text) {
|
||||
const tree = syntaxTree(this.view.state);
|
||||
const python = docOf(this.view)?.language === "python";
|
||||
const symbols = python ? pythonSymbolKinds(tree, doc) : undefined;
|
||||
const syntax = python ? pythonSyntaxRanges(tree, doc, symbols) : [];
|
||||
const covered = rangeCoverage(syntax);
|
||||
const retained: Array<{ from: number; to: number }> = [];
|
||||
const marks = syntax.map((r) => markFor(r.className, r.source).range(r.from, r.to));
|
||||
this.view.state.field(semanticField).between(0, doc.length, (from, to, decoration) => {
|
||||
if (
|
||||
!decoration.spec.pythonSyntax &&
|
||||
!covered(from, to) &&
|
||||
decoration.spec.symbol === doc.sliceString(from, to)
|
||||
) {
|
||||
marks.push(decoration.range(from, to));
|
||||
retained.push({ from, to });
|
||||
}
|
||||
});
|
||||
if (docOf(this.view)?.language === "python") {
|
||||
const retainedCovered = rangeCoverage(retained);
|
||||
for (const r of pythonFallbackRanges(
|
||||
tree,
|
||||
doc,
|
||||
this.rainbow ? (name) => `cm-sem-rainbow-${rainbowIndex(name)}` : undefined,
|
||||
symbols,
|
||||
))
|
||||
if (!covered(r.from, r.to) && !retainedCovered(r.from, r.to))
|
||||
marks.push(markFor(r.className, r.source).range(r.from, r.to));
|
||||
}
|
||||
this.view.dispatch({ effects: setSemantic.of(Decoration.set(marks, true)) });
|
||||
}
|
||||
decorate(doc: Text, tokens: SemanticTokens | null) {
|
||||
this.last = { doc, tokens };
|
||||
const ranges = tokens ? semanticRanges(doc, tokens, this.rainbow, docOf(this.view)?.language) : [];
|
||||
const ranges = semanticRanges(
|
||||
doc,
|
||||
tokens ?? { data: [], tokenTypes: [], tokenModifiers: [] },
|
||||
this.rainbow,
|
||||
docOf(this.view)?.language,
|
||||
syntaxTree(this.view.state),
|
||||
);
|
||||
this.view.dispatch({
|
||||
effects: setSemantic.of(
|
||||
Decoration.set(
|
||||
ranges.map((r) => markFor(r.className).range(r.from, r.to)),
|
||||
ranges.map((r) =>
|
||||
markFor(r.className, r.source, r.source ? undefined : doc.sliceString(r.from, r.to)).range(r.from, r.to),
|
||||
),
|
||||
true,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { findLanguageDescription, loadSupport } from "../core/cm/languages";
|
||||
import type { SemanticTokens } from "@/platform/ipc";
|
||||
import type { EditorDocContext } from "@/editor/api";
|
||||
import { lspDoc } from "./lsp";
|
||||
import { semanticExtension } from "./semantic";
|
||||
|
||||
const requests = vi.hoisted(() => [] as Array<(tokens: SemanticTokens) => void>);
|
||||
vi.mock("./lsp", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./lsp")>()),
|
||||
lspRequest: () => new Promise<SemanticTokens>((resolve) => requests.push(resolve)),
|
||||
}));
|
||||
vi.mock("./availability", () => ({
|
||||
featureBackoff: () => ({ active: () => false, mark: () => {} }),
|
||||
onLspRefresh: () => () => {},
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
requests.length = 0;
|
||||
});
|
||||
|
||||
describe("semantic colours while typing", () => {
|
||||
it("colours a newly completed builtin and parameters before any LSP response", async () => {
|
||||
const support = await loadSupport(findLanguageDescription("Python", "demo.py")!);
|
||||
vi.useFakeTimers();
|
||||
const view = new EditorView({
|
||||
parent: document.body,
|
||||
state: EditorState.create({
|
||||
doc: "def f(x):\n pri",
|
||||
extensions: [support!, lspDoc.of({ doc: 92, language: "python" } as EditorDocContext), semanticExtension()],
|
||||
}),
|
||||
});
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(view.dom.querySelector(".cm-sem-python-builtin")).toBeNull();
|
||||
view.dispatch({ changes: { from: view.state.doc.length, insert: "nt(x)" } });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(requests).toHaveLength(0);
|
||||
expect(view.dom.querySelector(".cm-sem-python-builtin")?.textContent).toBe("print");
|
||||
expect(view.dom.querySelectorAll(".cm-sem-parameter")).toHaveLength(2);
|
||||
} finally {
|
||||
view.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it("retains mapped server colours until the delayed response arrives, while updating syntax colours", async () => {
|
||||
const support = await loadSupport(findLanguageDescription("Python", "demo.py")!);
|
||||
vi.useFakeTimers();
|
||||
const view = new EditorView({
|
||||
parent: document.body,
|
||||
state: EditorState.create({
|
||||
doc: "def f(x):\n print(x)",
|
||||
extensions: [support!, lspDoc.of({ doc: 91, language: "python" } as EditorDocContext), semanticExtension()],
|
||||
}),
|
||||
});
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
requests.shift()!({
|
||||
tokenTypes: ["function", "parameter"],
|
||||
tokenModifiers: ["defaultLibrary"],
|
||||
data: [1, 4, 5, 0, 1, 1, 10, 1, 1, 0],
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(view.dom.querySelector(".cm-sem-python-builtin")?.textContent).toBe("print");
|
||||
expect(view.dom.querySelector(".cm-sem-parameter")?.textContent).toBe("x");
|
||||
|
||||
view.dispatch({ changes: { from: 0, insert: "# comment\n" } });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(view.dom.querySelector(".cm-sem-python-builtin")?.textContent).toBe("print");
|
||||
expect(view.dom.querySelector(".cm-sem-parameter")?.textContent).toBe("x");
|
||||
expect(view.dom.querySelector(".cm-sem-python-definition")?.textContent).toBe("f");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(view.dom.querySelector(".cm-sem-python-builtin")?.textContent).toBe("print");
|
||||
requests.shift()!({
|
||||
tokenTypes: ["function", "parameter"],
|
||||
tokenModifiers: ["defaultLibrary"],
|
||||
data: [2, 4, 5, 0, 1, 2, 10, 1, 1, 0],
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(view.dom.querySelector(".cm-sem-python-builtin")?.textContent).toBe("print");
|
||||
} finally {
|
||||
view.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -69,6 +69,16 @@ const markdown = {
|
||||
".tok-invalid": { color: "var(--syn-invalid)" },
|
||||
};
|
||||
|
||||
function pythonStyle(token: string, fallback: string) {
|
||||
return {
|
||||
color: `var(--syn-${token}, var(--syn-${fallback}))`,
|
||||
fontWeight: `var(--syn-${token}-bold, inherit)`,
|
||||
fontStyle: `var(--syn-${token}-italic, inherit)`,
|
||||
backgroundColor: `var(--syn-${token}-bg, transparent)`,
|
||||
textDecoration: `var(--syn-${token}-effect, none)`,
|
||||
};
|
||||
}
|
||||
|
||||
export const lspTheme = EditorView.theme({
|
||||
// --- completion (IDEA lookup: icon column, bold blue match, grey tail/type, advertiser) ---
|
||||
".cm-tooltip.cm-tooltip-autocomplete.cm-lsp-completion": {
|
||||
@@ -348,15 +358,31 @@ export const lspTheme = EditorView.theme({
|
||||
".cm-lsp-inlay-pl": { marginLeft: "4px" },
|
||||
".cm-lsp-inlay-pr": { marginRight: "4px" },
|
||||
|
||||
".cm-sem-python-method-call": pythonStyle("python-method-call", "function-call"),
|
||||
".cm-sem-python-annotation": pythonStyle("python-annotation", "type"),
|
||||
".cm-sem-python-parentheses": pythonStyle("python-parentheses", "punct"),
|
||||
".cm-sem-python-brackets": pythonStyle("python-brackets", "punct"),
|
||||
".cm-sem-python-braces": pythonStyle("python-braces", "punct"),
|
||||
".cm-sem-python-definition": pythonStyle("function", "function"),
|
||||
".cm-sem-python-nested": pythonStyle("python-nested-function", "function"),
|
||||
".cm-sem-python-predefined-definition": pythonStyle("python-predefined-definition", "function"),
|
||||
".cm-sem-python-predefined-usage": pythonStyle("python-predefined-usage", "self"),
|
||||
".cm-sem-python-docstring, .cm-sem-python-docstring *": pythonStyle("docstring", "string"),
|
||||
".cm-sem-python-bytes": pythonStyle("python-bytes", "string"),
|
||||
".cm-sem-python-escape": pythonStyle("escape", "escape"),
|
||||
".cm-sem-python-format": pythonStyle("python-format", "escape"),
|
||||
".cm-sem-python-keyword-arg": pythonStyle("keyword-arg", "param"),
|
||||
// --- semantic tokens -----------------------------------------------------
|
||||
".cm-sem-type, .cm-sem-typeParameter": { color: "var(--syn-type)" },
|
||||
// IDEA colours declarations, calls stay (nearly) plain text.
|
||||
".cm-sem-function": { color: "var(--syn-function-call, var(--syn-function))" },
|
||||
".cm-sem-function.cm-sem-declaration": { color: "var(--syn-function)" },
|
||||
".cm-sem-function.cm-sem-builtin": { color: "var(--syn-builtin, var(--syn-function))" },
|
||||
".cm-sem-function.cm-sem-python-method-call": pythonStyle("python-method-call", "function-call"),
|
||||
".cm-sem-macro": { color: "var(--syn-annotation)" },
|
||||
".cm-sem-annotation": { color: "var(--syn-decorator, var(--syn-annotation))" },
|
||||
".cm-sem-constant": { color: "var(--syn-constant)" },
|
||||
".cm-sem-variable": { color: "var(--syn-variable)" },
|
||||
".cm-sem-property": { color: "var(--syn-property)" },
|
||||
".cm-sem-parameter": { color: "var(--syn-param, var(--syn-variable))" },
|
||||
".cm-sem-self": { color: "var(--syn-self, var(--syn-keyword))" },
|
||||
@@ -364,6 +390,7 @@ export const lspTheme = EditorView.theme({
|
||||
// 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-builtin.cm-sem-python-builtin": pythonStyle("predefined", "builtin"),
|
||||
".cm-sem-static": { fontStyle: "var(--syn-static-italic, italic)" },
|
||||
".cm-sem-property.cm-sem-static, .cm-sem-property.cm-sem-static *": {
|
||||
color: "var(--syn-static-property, var(--syn-property))",
|
||||
@@ -380,6 +407,13 @@ export const lspTheme = EditorView.theme({
|
||||
textUnderlineOffset: "3px",
|
||||
},
|
||||
".cm-sem-deprecated": { textDecoration: "line-through" },
|
||||
".cm-sem-python-local-variable": pythonStyle("python-local-variable", "variable"),
|
||||
".cm-sem-global.cm-sem-python-global-variable": pythonStyle("python-global-variable", "global"),
|
||||
".cm-sem-python-instance-attribute": pythonStyle("python-instance-attribute", "property"),
|
||||
".cm-sem-python-class-attribute": pythonStyle("python-class-attribute", "property"),
|
||||
".cm-sem-python-constant": pythonStyle("python-constant", "constant"),
|
||||
".cm-sem-python-property": pythonStyle("python-property", "property"),
|
||||
".cm-sem-python-type-parameter": pythonStyle("python-type-parameter", "type"),
|
||||
// Semantic highlighting: after the base classes so a parameter's colour gives way, and over the
|
||||
// syntax highlighter's span inside.
|
||||
...Object.fromEntries(
|
||||
|
||||
@@ -57,6 +57,9 @@ describe("coverageSupports", () => {
|
||||
expect(coverageSupports(cfg("pnpm", ["run", "dev"]))).toBe(false);
|
||||
expect(coverageSupports(cfg("/v/bin/python3", ["-m", "pytest"]))).toBe(true);
|
||||
expect(coverageSupports(cfg("uv", ["run", "pytest"]))).toBe(true);
|
||||
expect(coverageSupports(cfg("/v/bin/ruby", ["-Ilib", "test/a_test.rb"]))).toBe(true);
|
||||
expect(coverageSupports(cfg("bundle", ["exec", "rspec"], { isTest: true }))).toBe(true);
|
||||
expect(coverageSupports(cfg("bundle", ["exec", "rake", "db:migrate"]))).toBe(false);
|
||||
expect(coverageSupports(cfg("make", ["test"]))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ const runsJvmTests = (args: readonly string[]) =>
|
||||
/**
|
||||
* Whether a coverage runner is known for `config` (cheap; the backend decides for real):
|
||||
* cargo test / run / nextest, go test, Maven / Gradle tests, package.json test scripts,
|
||||
* Node.js programs and Python.
|
||||
* Node.js programs, Python and Ruby (scripts, rspec, test rake tasks).
|
||||
*/
|
||||
export function coverageSupports(config: Pick<RunConfig, "command" | "args" | "kind" | "isTest">): boolean {
|
||||
const program = programName(config.command);
|
||||
@@ -43,6 +43,13 @@ export function coverageSupports(config: Pick<RunConfig, "command" | "args" | "k
|
||||
case "tsx":
|
||||
case "ts-node":
|
||||
return true;
|
||||
case "ruby":
|
||||
case "rspec":
|
||||
return true;
|
||||
case "bundle":
|
||||
case "rake":
|
||||
case "rails":
|
||||
return config.isTest;
|
||||
}
|
||||
return (
|
||||
config.kind === "python" ||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Coverage reports collected elsewhere: "Import a report collected in CI from
|
||||
* disk" (lcov, JaCoCo XML, Go cover profile) and "Manage Coverage Reports"
|
||||
* disk" (lcov, JaCoCo XML, Go cover profile, SimpleCov) and "Manage Coverage Reports"
|
||||
* (choose the shown suite, delete suites and remembered imports).
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
@@ -35,7 +35,7 @@ async function pickReport(): Promise<string | null> {
|
||||
filters: [
|
||||
{
|
||||
name: "Coverage reports (lcov, JaCoCo XML, Go cover profile)",
|
||||
extensions: ["info", "lcov", "xml", "out", "txt", "cov", "coverprofile"],
|
||||
extensions: ["info", "lcov", "xml", "out", "txt", "cov", "coverprofile", "json"],
|
||||
},
|
||||
{ name: "All files", extensions: ["*"] },
|
||||
],
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { ContextMenuHost } from "@/ui";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { adoptSession, appendConsole, useDebug } from "../state";
|
||||
import { DebugConsole } from "../ui/DebugConsole";
|
||||
|
||||
const clipboard = vi.hoisted(() => ({ writeText: vi.fn(async () => {}) }));
|
||||
|
||||
vi.mock("@tanstack/react-virtual", () => ({
|
||||
useVirtualizer: ({ count }: { count: number }) => ({
|
||||
getTotalSize: () => count * 18,
|
||||
getVirtualItems: () =>
|
||||
Array.from({ length: count }, (_, index) => ({ index, start: index * 18, size: 18, key: index })),
|
||||
scrollToIndex: () => {},
|
||||
measureElement: () => {},
|
||||
measure: () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
usePlatform.setState({ os: "macos" });
|
||||
useDebug.setState({ sessions: {}, console: {}, active: null });
|
||||
clipboard.writeText.mockClear();
|
||||
Object.defineProperty(navigator, "clipboard", { value: clipboard, configurable: true });
|
||||
window.getSelection()?.removeAllRanges();
|
||||
});
|
||||
|
||||
function setup(text = "alpha beta\ngamma Beta\nbeta again\n") {
|
||||
const session = adoptSession(1);
|
||||
appendConsole(1, "stdout", text);
|
||||
const view = render(
|
||||
<>
|
||||
<DebugConsole session={session} />
|
||||
<ContextMenuHost />
|
||||
<div data-testid="outside">outside text</div>
|
||||
</>,
|
||||
);
|
||||
const log = screen.getByRole("log");
|
||||
return { session, log, view };
|
||||
}
|
||||
|
||||
function openFind(log: HTMLElement) {
|
||||
fireEvent.keyDown(log, { key: "f", code: "KeyF", metaKey: true });
|
||||
return screen.getByRole("search");
|
||||
}
|
||||
|
||||
function input() {
|
||||
return screen.getByRole("textbox", { name: "Find in console" });
|
||||
}
|
||||
|
||||
function type(query: string) {
|
||||
fireEvent.change(input(), { target: { value: query } });
|
||||
}
|
||||
|
||||
function markCount(container: HTMLElement) {
|
||||
return container.querySelectorAll("[data-find-match]").length;
|
||||
}
|
||||
|
||||
describe("Debug console find", () => {
|
||||
it("opens on ⌘F, searches as you type and highlights every match", () => {
|
||||
const { log, view } = setup();
|
||||
expect(openFind(log)).toBeInTheDocument();
|
||||
expect(input()).toHaveFocus();
|
||||
|
||||
type("beta");
|
||||
expect(screen.getByText("1/3")).toBeInTheDocument();
|
||||
expect(markCount(view.container)).toBe(3);
|
||||
expect(view.container.querySelectorAll("[data-find-current]")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("moves through matches with Enter / Shift+Enter and wraps around", () => {
|
||||
const { log } = setup();
|
||||
openFind(log);
|
||||
type("beta");
|
||||
const bar = screen.getByRole("search");
|
||||
expect(screen.getByText("1/3")).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(bar, { key: "Enter" });
|
||||
expect(screen.getByText("2/3")).toBeInTheDocument();
|
||||
fireEvent.keyDown(bar, { key: "Enter", shiftKey: true });
|
||||
expect(screen.getByText("1/3")).toBeInTheDocument();
|
||||
fireEvent.keyDown(bar, { key: "Enter", shiftKey: true });
|
||||
expect(screen.getByText("3/3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("supports match case and regex", () => {
|
||||
const { log, view } = setup();
|
||||
openFind(log);
|
||||
type("beta");
|
||||
expect(screen.getByText("1/3")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Match Case" }));
|
||||
expect(screen.getByText("1/2")).toBeInTheDocument();
|
||||
expect(markCount(view.container)).toBe(2);
|
||||
|
||||
fireEvent.change(input(), { target: { value: "(" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Regex" }));
|
||||
expect(screen.getByText("Bad regex")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes on Escape, clears the highlights and returns focus to the console", () => {
|
||||
const { log, view } = setup();
|
||||
openFind(log);
|
||||
type("beta");
|
||||
expect(markCount(view.container)).toBe(3);
|
||||
|
||||
fireEvent.keyDown(input(), { key: "Escape" });
|
||||
expect(screen.queryByRole("search")).not.toBeInTheDocument();
|
||||
expect(markCount(view.container)).toBe(0);
|
||||
expect(log).toHaveFocus();
|
||||
});
|
||||
|
||||
it("offers the Run console's context menu", () => {
|
||||
const { log } = setup();
|
||||
fireEvent.contextMenu(log);
|
||||
for (const label of ["Copy", "Select All", "Copy All", "Find…", "Clear All"]) {
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
}
|
||||
// Nothing is selected, so Copy has nothing to copy.
|
||||
expect(screen.getByText("Copy").closest("[aria-disabled]")).not.toBeNull();
|
||||
// Send EOF is the Run console's stdin; the debug console has no such input.
|
||||
expect(screen.queryByText("Send EOF")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the find bar from the context menu", () => {
|
||||
const { log } = setup();
|
||||
fireEvent.contextMenu(log);
|
||||
fireEvent.click(screen.getByText("Find…"));
|
||||
expect(screen.getByRole("search")).toBeInTheDocument();
|
||||
expect(input()).toHaveFocus();
|
||||
});
|
||||
|
||||
it("copies the whole buffer without doubling the line breaks", async () => {
|
||||
// Console lines keep their trailing "\n", so joining them with one would add blank lines.
|
||||
const { log } = setup();
|
||||
fireEvent.contextMenu(log);
|
||||
fireEvent.click(screen.getByText("Copy All"));
|
||||
await waitFor(() => expect(clipboard.writeText).toHaveBeenCalledWith("alpha beta\ngamma Beta\nbeta again\n"));
|
||||
});
|
||||
|
||||
it("leaves Enter over a toggle button to the button, not to the next match", () => {
|
||||
const { log } = setup();
|
||||
openFind(log);
|
||||
type("beta");
|
||||
expect(screen.getByText("1/3")).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(screen.getByRole("button", { name: "Match Case" }), { key: "Enter" });
|
||||
expect(screen.getByText("1/3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the span's styling on a highlighted match", () => {
|
||||
const { log, view } = setup("\u001b[3mitalic beta\u001b[0m\n");
|
||||
openFind(log);
|
||||
type("beta");
|
||||
const mark = view.container.querySelector("[data-find-match]") as HTMLElement;
|
||||
expect(mark).not.toBeNull();
|
||||
expect(mark.style.fontStyle).toBe("italic");
|
||||
});
|
||||
|
||||
it("keeps Copy disabled when the selection is outside the console", () => {
|
||||
const { log } = setup();
|
||||
const outside = screen.getByTestId("outside");
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(outside);
|
||||
window.getSelection()!.removeAllRanges();
|
||||
window.getSelection()!.addRange(range);
|
||||
|
||||
fireEvent.contextMenu(log);
|
||||
expect(screen.getByText("Copy").closest("[aria-disabled]")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,95 @@
|
||||
import { useLayoutEffect, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { copySelectionOnKeyDown } from "@/ui/selectableText";
|
||||
import { copySelectionOnKeyDown, copyText } from "@/ui/selectableText";
|
||||
import { EmptyState, IconButton, showContextMenu, T, type MenuItem } from "@/ui";
|
||||
import { formatBinding, hasPrimaryModifier, hostBinding, registerKeyClaim } from "@/platform/keymap";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { useWorkflowSettings } from "@/platform/workflowSettings";
|
||||
import { spanStyle } from "@/ui/ansi";
|
||||
import { spanStyle, type Span, type SpanStyle } from "@/ui/ansi";
|
||||
import ansi from "@/ui/ansi.module.css";
|
||||
import { ConsoleFindBar } from "@/ui/ConsoleFindBar";
|
||||
import {
|
||||
DEFAULT_FIND_OPTIONS,
|
||||
findInLines,
|
||||
type ConsoleMatch,
|
||||
type FindOptions,
|
||||
type FindResult,
|
||||
} from "@/ui/consoleFind";
|
||||
import { appendConsole, clearConsole, evaluate, isLive, useDebug, type DebugSession } from "../state";
|
||||
import { pushHistory, readHistory, setConsoleHistoryBrowser } from "./expressionHistory";
|
||||
import { useExpressionCompletion } from "./ExpressionCompletion";
|
||||
import styles from "./DebugToolWindow.module.css";
|
||||
|
||||
const EMPTY_FIND: FindResult = { matches: [], overflow: false, invalid: false };
|
||||
|
||||
const MARK_COLOR = "var(--bg-search-match)";
|
||||
const CURRENT_MARK_COLOR = "var(--bg-search-match-current)";
|
||||
|
||||
interface Segment {
|
||||
text: string;
|
||||
style: SpanStyle;
|
||||
/** The piece is inside a find match. */
|
||||
mark: boolean;
|
||||
/** …and inside the current one. */
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
/** Splits a line's styled spans at find-match boundaries (the Run console's `segmentLine`, without links). */
|
||||
function findSegments(spans: readonly Span[], marks: readonly ConsoleMatch[], active?: ConsoleMatch): Segment[] {
|
||||
const out: Segment[] = [];
|
||||
let pos = 0;
|
||||
let mi = 0;
|
||||
for (const span of spans) {
|
||||
const { text, ...style } = span;
|
||||
let offset = 0;
|
||||
while (offset < text.length) {
|
||||
const abs = pos + offset;
|
||||
while (mi < marks.length && marks[mi]!.end <= abs) mi++;
|
||||
const mark = marks[mi];
|
||||
if (mark && mark.start <= abs) {
|
||||
const take = Math.min(mark.end - abs, text.length - offset);
|
||||
out.push({ text: text.slice(offset, offset + take), style, mark: true, current: mark === active });
|
||||
offset += take;
|
||||
} else if (mark && mark.start < pos + text.length) {
|
||||
const take = Math.min(mark.start - abs, text.length - offset);
|
||||
out.push({ text: text.slice(offset, offset + take), style, mark: false, current: false });
|
||||
offset += take;
|
||||
} else {
|
||||
out.push({ text: text.slice(offset), style, mark: false, current: false });
|
||||
offset = text.length;
|
||||
}
|
||||
}
|
||||
pos += text.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A match keeps the span's colours but takes the search background (as IDEA does). */
|
||||
function segmentNode(seg: Segment, key: number) {
|
||||
if (!seg.mark)
|
||||
return (
|
||||
<span key={key} style={spanStyle(seg.style)}>
|
||||
{seg.text}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<mark
|
||||
key={key}
|
||||
className={styles.findMatch}
|
||||
style={{
|
||||
...spanStyle(seg.style),
|
||||
backgroundColor: seg.current ? CURRENT_MARK_COLOR : MARK_COLOR,
|
||||
color: "var(--fg-search-match)",
|
||||
}}
|
||||
data-find-match=""
|
||||
data-find-current={seg.current ? "" : undefined}
|
||||
>
|
||||
{seg.text}
|
||||
</mark>
|
||||
);
|
||||
}
|
||||
|
||||
export function DebugServiceConsole({ sessionId }: { sessionId: number }) {
|
||||
const session = useDebug((s) => s.sessions[sessionId]);
|
||||
return session ? (
|
||||
@@ -24,6 +102,7 @@ export function DebugServiceConsole({ sessionId }: { sessionId: number }) {
|
||||
export function DebugConsole({ session, interactive = true }: { session: DebugSession; interactive?: boolean }) {
|
||||
const lines = useDebug((s) => s.console[session.id]);
|
||||
const scroller = useRef<HTMLDivElement>(null);
|
||||
const inner = useRef<HTMLDivElement>(null);
|
||||
// Scroll to End: follows new output; scrolling up turns it off, back to the end turns it on.
|
||||
const [follow, setFollow] = useState(true);
|
||||
const stick = useRef(true);
|
||||
@@ -39,6 +118,15 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const completion = useExpressionCompletion(inputRef, input, setInput, session);
|
||||
|
||||
const [findOpen, setFindOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [findOptions, setFindOptions] = useState<FindOptions>(DEFAULT_FIND_OPTIONS);
|
||||
const [activeMatchIndex, setActiveMatchIndex] = useState(0);
|
||||
const findInput = useRef<HTMLInputElement>(null);
|
||||
/** Line the next search starts from (the top of the viewport when the query changed). */
|
||||
const findAnchor = useRef(0);
|
||||
const scrolledMatch = useRef<string | null>(null);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: lines?.length ?? 0,
|
||||
getScrollElement: () => scroller.current,
|
||||
@@ -63,6 +151,99 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
|
||||
});
|
||||
}, [lines, virtualizer, follow, softWrap]);
|
||||
|
||||
// Find (⌘F): search the whole buffer, not just the rendered rows.
|
||||
const found = useMemo(
|
||||
() => (findOpen ? findInLines(lines ?? [], query, findOptions) : EMPTY_FIND),
|
||||
[findOpen, lines, query, findOptions],
|
||||
);
|
||||
const activeIndex = found.matches.length ? Math.min(Math.max(activeMatchIndex, 0), found.matches.length - 1) : -1;
|
||||
const current = activeIndex >= 0 ? found.matches[activeIndex] : undefined;
|
||||
const marksByLine = useMemo(() => {
|
||||
const byLine = new Map<number, ConsoleMatch[]>();
|
||||
found.matches.forEach((match) => {
|
||||
const marks = byLine.get(match.line);
|
||||
if (marks) marks.push(match);
|
||||
else byLine.set(match.line, [match]);
|
||||
});
|
||||
return byLine;
|
||||
}, [found]);
|
||||
|
||||
// The line at the top of the viewport: typing searches from there, as IDEA does.
|
||||
const topLine = () => {
|
||||
const offset = scroller.current?.scrollTop ?? 0;
|
||||
const items = virtualizer.getVirtualItems();
|
||||
const first = items.find((item) => item.start + item.size > offset) ?? items[0];
|
||||
return first?.index ?? 0;
|
||||
};
|
||||
const openFind = () => {
|
||||
findAnchor.current = topLine();
|
||||
// Opening with selected output searches for it (a single line, like the terminal).
|
||||
if (!findOpen) {
|
||||
const text = selectedText();
|
||||
if (text) setQuery(text);
|
||||
}
|
||||
scrolledMatch.current = null;
|
||||
setFindOpen(true);
|
||||
setFollow(false);
|
||||
findInput.current?.focus();
|
||||
findInput.current?.select();
|
||||
};
|
||||
const closeFind = () => {
|
||||
scrolledMatch.current = null;
|
||||
setFindOpen(false);
|
||||
scroller.current?.focus();
|
||||
};
|
||||
const changeQuery = (value: string) => {
|
||||
findAnchor.current = topLine();
|
||||
setQuery(value);
|
||||
};
|
||||
const changeOptions = (next: FindOptions) => {
|
||||
findAnchor.current = topLine();
|
||||
setFindOptions(next);
|
||||
};
|
||||
const goToMatch = (direction: 1 | -1) => {
|
||||
const total = found.matches.length;
|
||||
if (!total) return;
|
||||
const from = activeMatchIndex >= 0 && activeMatchIndex < total ? activeMatchIndex : activeIndex;
|
||||
setActiveMatchIndex((from + direction + total) % total);
|
||||
};
|
||||
|
||||
const findWasOpen = useRef(false);
|
||||
useEffect(() => {
|
||||
if (findOpen) {
|
||||
findWasOpen.current = true;
|
||||
findInput.current?.focus();
|
||||
findInput.current?.select();
|
||||
} else if (findWasOpen.current) {
|
||||
// Closed from the toolbar rather than the bar: put the caret back in the console.
|
||||
findWasOpen.current = false;
|
||||
scroller.current?.focus();
|
||||
}
|
||||
}, [findOpen]);
|
||||
|
||||
// The bar owns Escape / Enter / F3 / ⌘G while it is focused; keep the IDE keymap out.
|
||||
useEffect(() => {
|
||||
if (!findOpen) return;
|
||||
return registerKeyClaim((e) => !!(e.target as Element | null)?.closest?.("[data-console-find]"));
|
||||
}, [findOpen]);
|
||||
|
||||
// A new query (or option) starts from the viewport, not from the top of the output.
|
||||
useEffect(() => {
|
||||
if (!findOpen) return;
|
||||
const next = found.matches.findIndex((match) => match.line >= findAnchor.current);
|
||||
setActiveMatchIndex(next >= 0 ? next : 0);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- new output keeps the current match
|
||||
}, [findOpen, query, findOptions]);
|
||||
|
||||
// Bring the current match into view.
|
||||
useEffect(() => {
|
||||
if (!findOpen || !current) return;
|
||||
const key = `${current.lineId}:${current.start}`;
|
||||
if (scrolledMatch.current === key) return;
|
||||
virtualizer.scrollToIndex(current.line, { align: "center" });
|
||||
scrolledMatch.current = key;
|
||||
}, [findOpen, current, virtualizer]);
|
||||
|
||||
const submit = async () => {
|
||||
const e = input.trim();
|
||||
if (!e) return;
|
||||
@@ -112,8 +293,95 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
|
||||
}
|
||||
};
|
||||
|
||||
/** Rows are virtualized, so "everything" is a selection of the container, not of the mounted rows. */
|
||||
const selectAll = () => {
|
||||
if (inner.current) window.getSelection()?.selectAllChildren(inner.current);
|
||||
};
|
||||
const allSelected = () => {
|
||||
const el = inner.current;
|
||||
const s = window.getSelection();
|
||||
return (
|
||||
!!el &&
|
||||
!!s &&
|
||||
!s.isCollapsed &&
|
||||
s.anchorNode === el &&
|
||||
s.focusNode === el &&
|
||||
s.anchorOffset === 0 &&
|
||||
s.focusOffset === el.childNodes.length
|
||||
);
|
||||
};
|
||||
const bufferText = () => (lines ?? []).map((l) => l.text).join("");
|
||||
/** The selected text; after Select All the whole buffer, including rows that are not mounted. */
|
||||
const selectedOutput = () => {
|
||||
if (allSelected()) return bufferText();
|
||||
const s = window.getSelection();
|
||||
const el = scroller.current;
|
||||
if (!s || !el || !s.anchorNode || !el.contains(s.anchorNode)) return "";
|
||||
return s.toString();
|
||||
};
|
||||
const selectedText = () => {
|
||||
const el = scroller.current;
|
||||
const selection = window.getSelection();
|
||||
if (!el || !selection || !selection.anchorNode || !el.contains(selection.anchorNode)) return "";
|
||||
const text = selection.toString();
|
||||
return text && !text.includes("\n") ? text.trim() : "";
|
||||
};
|
||||
|
||||
const onOutputKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.nativeEvent.isComposing) return;
|
||||
if (hasPrimaryModifier(e) && !e.altKey && !e.shiftKey) {
|
||||
const key = e.key.toLowerCase();
|
||||
if (key === "f") {
|
||||
e.preventDefault();
|
||||
openFind();
|
||||
return;
|
||||
}
|
||||
if (key === "a") {
|
||||
e.preventDefault();
|
||||
selectAll();
|
||||
return;
|
||||
}
|
||||
}
|
||||
copySelectionOnKeyDown(e, selectedOutput);
|
||||
};
|
||||
|
||||
const onContextMenu = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const selection = selectedOutput();
|
||||
showContextMenu(e, [
|
||||
{
|
||||
label: "Copy",
|
||||
icon: "copy",
|
||||
shortcut: formatBinding(hostBinding("Meta+C")),
|
||||
disabled: !selection,
|
||||
run: () => void copyText(selection),
|
||||
},
|
||||
{ label: "Select All", shortcut: formatBinding(hostBinding("Meta+A")), run: selectAll },
|
||||
{ label: "Copy All", run: () => void copyText(bufferText()) },
|
||||
{ label: "Find…", icon: "search", shortcut: formatBinding(hostBinding("Meta+F")), run: openFind },
|
||||
{ kind: "separator" },
|
||||
{ label: "Clear All", icon: "trash", run: () => clearConsole(session.id) },
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.console}>
|
||||
{findOpen && (
|
||||
<ConsoleFindBar
|
||||
query={query}
|
||||
onChange={changeQuery}
|
||||
options={findOptions}
|
||||
onOptionsChange={changeOptions}
|
||||
count={found.matches.length}
|
||||
active={activeIndex}
|
||||
overflow={found.overflow}
|
||||
invalid={found.invalid}
|
||||
inputRef={findInput}
|
||||
onNext={() => goToMatch(1)}
|
||||
onPrevious={() => goToMatch(-1)}
|
||||
onClose={closeFind}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={scroller}
|
||||
className={clsx(styles.consoleOutput, ansi.palette, !softWrap && styles.noWrap)}
|
||||
@@ -123,19 +391,16 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
|
||||
const atEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
|
||||
if (atEnd !== stick.current) setFollow(atEnd);
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
showContextMenu(e, [{ label: "Clear All", icon: "trash", run: () => clearConsole(session.id) }]);
|
||||
}}
|
||||
onContextMenu={onContextMenu}
|
||||
role="log"
|
||||
onKeyDown={copySelectionOnKeyDown}
|
||||
onKeyDown={onOutputKeyDown}
|
||||
aria-label={tx("Debug console")}
|
||||
tabIndex={0}
|
||||
>
|
||||
{!lines?.length && session.launch?.request === "attach" && (
|
||||
<T>The attached process may not provide standard output to the debugger.</T>
|
||||
)}
|
||||
<div className={styles.consoleInner} style={{ height: virtualizer.getTotalSize() }}>
|
||||
<div ref={inner} className={styles.consoleInner} style={{ height: virtualizer.getTotalSize() }}>
|
||||
{virtualizer.getVirtualItems().map((item) => {
|
||||
const l = lines?.[item.index];
|
||||
if (!l) return null;
|
||||
@@ -147,11 +412,7 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
|
||||
className={clsx(styles.out, styles[`cat-${l.category}`])}
|
||||
style={{ transform: `translateY(${item.start}px)` }}
|
||||
>
|
||||
{l.spans.map((sp, i) => (
|
||||
<span key={i} style={spanStyle(sp)}>
|
||||
{sp.text}
|
||||
</span>
|
||||
))}
|
||||
{findSegments(l.spans, marksByLine.get(item.index) ?? [], current).map(segmentNode)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -251,6 +251,11 @@
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
/* Find matches in the console output. */
|
||||
.findMatch {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Soft-Wrap off: long lines scroll horizontally. */
|
||||
.noWrap {
|
||||
white-space: pre;
|
||||
|
||||
@@ -109,7 +109,7 @@ export const ICLS_COLORS: Record<string, string[]> = {
|
||||
DELETED_LINES_COLOR: ["--gutter-deleted"],
|
||||
};
|
||||
|
||||
type Part = "FOREGROUND" | "BACKGROUND" | "EFFECT_COLOR";
|
||||
type Part = "FOREGROUND" | "BACKGROUND" | "EFFECT_COLOR" | "ERROR_STRIPE_COLOR";
|
||||
|
||||
/** `.icls` `<attributes>` options → [part, token] pairs. */
|
||||
export const ICLS_ATTRIBUTES: Record<string, Array<[Part, string]>> = {
|
||||
@@ -117,9 +117,19 @@ export const ICLS_ATTRIBUTES: Record<string, Array<[Part, string]>> = {
|
||||
["FOREGROUND", "--fg-editor"],
|
||||
["BACKGROUND", "--bg-editor"],
|
||||
],
|
||||
DIFF_MODIFIED: [["BACKGROUND", "--diff-modified"]],
|
||||
DIFF_INSERTED: [["BACKGROUND", "--diff-inserted"]],
|
||||
DIFF_DELETED: [["BACKGROUND", "--diff-deleted"]],
|
||||
DIFF_MODIFIED: [
|
||||
["BACKGROUND", "--diff-modified"],
|
||||
["ERROR_STRIPE_COLOR", "--diff-modified-stripe"],
|
||||
],
|
||||
DIFF_INSERTED: [
|
||||
["BACKGROUND", "--diff-inserted"],
|
||||
["ERROR_STRIPE_COLOR", "--diff-inserted-stripe"],
|
||||
],
|
||||
DIFF_DELETED: [
|
||||
["BACKGROUND", "--diff-deleted"],
|
||||
["ERROR_STRIPE_COLOR", "--diff-deleted-stripe"],
|
||||
],
|
||||
DIFF_CONFLICT: [["ERROR_STRIPE_COLOR", "--diff-conflict-stripe"]],
|
||||
DEFAULT_KEYWORD: [["FOREGROUND", "--syn-keyword"]],
|
||||
DEFAULT_STRING: [["FOREGROUND", "--syn-string"]],
|
||||
DEFAULT_NUMBER: [["FOREGROUND", "--syn-number"]],
|
||||
|
||||
@@ -24,8 +24,8 @@ import styles from "./Run.module.css";
|
||||
import { useConsoleSelection } from "./useConsoleSelection";
|
||||
import { hostBinding, formatBinding, hasPrimaryModifier, registerKeyClaim } from "@/platform/keymap";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { ConsoleFindBar } from "./FindBar";
|
||||
import { DEFAULT_FIND_OPTIONS, findInLines, type FindOptions } from "./find";
|
||||
import { ConsoleFindBar } from "@/ui/ConsoleFindBar";
|
||||
import { DEFAULT_FIND_OPTIONS, findInLines, type FindOptions } from "@/ui/consoleFind";
|
||||
|
||||
const ROW = 20;
|
||||
|
||||
@@ -343,7 +343,7 @@ export function ConsoleView({
|
||||
// The bar owns Escape / Enter / F3 / ⌘G while it is focused; keep the IDE keymap out.
|
||||
useEffect(() => {
|
||||
if (!findOpen) return;
|
||||
return registerKeyClaim((e) => !!(e.target as Element | null)?.closest?.("[data-run-find]"));
|
||||
return registerKeyClaim((e) => !!(e.target as Element | null)?.closest?.("[data-console-find]"));
|
||||
}, [findOpen]);
|
||||
|
||||
// A new query (or option) starts from the viewport, not from the top of the output.
|
||||
|
||||
@@ -780,35 +780,6 @@
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Find bar (⌘F) */
|
||||
.find {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-elev);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.findField {
|
||||
flex: 0 1 360px;
|
||||
min-width: 160px;
|
||||
}
|
||||
.findCount {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
color: var(--fg-subtle);
|
||||
padding: 0 6px;
|
||||
white-space: nowrap;
|
||||
min-width: 56px;
|
||||
}
|
||||
.findCountNone {
|
||||
color: var(--danger);
|
||||
}
|
||||
.findSpacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.console:focus .inputCaret {
|
||||
background: var(--fg);
|
||||
animation: caretBlink 1s steps(1) infinite;
|
||||
|
||||
@@ -21,7 +21,7 @@ export function fileRole(relPath: string, content?: string, windows = isWindows(
|
||||
(django && name === "tests.py") ||
|
||||
/_test\.go$/.test(name) ||
|
||||
/Test\.php$/.test(name) ||
|
||||
/_(spec|test)\.rb$/.test(name) ||
|
||||
/_(spec|test)\.rb$|^test_.*\.rb$/.test(name) ||
|
||||
/(Test|Tests|Spec|IT)\.(kt|java|scala|groovy)$/.test(name) ||
|
||||
(/(^|\/)tests\/[^/]+\.rs$/.test(relPath) && name !== "mod.rs")
|
||||
)
|
||||
@@ -43,8 +43,8 @@ function testKinds(relPath: string): string[] {
|
||||
if (/\.[cm]?[jt]sx?$/.test(relPath)) return ["npm", "deno", "bun"];
|
||||
if (/\.py$/.test(relPath)) return ["pytest", "python", "django-test"];
|
||||
if (/\.php$/.test(relPath)) return ["phpunit"];
|
||||
if (/_spec\.rb$/.test(relPath)) return ["rspec", "rake"];
|
||||
if (/_test\.rb$/.test(relPath)) return ["rake", "rspec"];
|
||||
if (/_spec\.rb$/.test(relPath)) return ["rspec", "rake", "ruby"];
|
||||
if (/(^|\/)test_[^/]*\.rb$|_test\.rb$/.test(relPath)) return ["rake", "rspec", "ruby"];
|
||||
if (/\.go$/.test(relPath)) return ["go"];
|
||||
if (/\.rs$/.test(relPath)) return ["cargo"];
|
||||
if (/\.(kt|java|scala|groovy)$/.test(relPath)) return ["gradle", "maven"];
|
||||
@@ -180,6 +180,9 @@ export function testConfigFor(relPath: string, configs: RunConfig[], base?: RunC
|
||||
const key = /(^|:)spec$/.test(task) ? "SPEC" : /(^|:)test$/.test(task) ? "TEST" : null;
|
||||
return key ? derived(base, relPath, { args: [...base.args, `${key}=${file}`] }) : base;
|
||||
}
|
||||
case "ruby":
|
||||
// The detected Minitest runner (`ruby … -e <runner> test`): its last argument is what to load.
|
||||
return derived(base, relPath, { args: [...base.args.slice(0, -1), file] });
|
||||
case "django-test": {
|
||||
const tests = djangoTestsOf(base, [djangoLabel(file)]);
|
||||
return tests ? derived(base, relPath, tests) : null;
|
||||
|
||||
@@ -12,9 +12,11 @@ export interface FileTarget {
|
||||
/**
|
||||
* `path`: resolve against the run directory; `name`: a file name from a JVM
|
||||
* stack frame — look it up in the project (`path` then is the file under its
|
||||
* package directory, `com/acme/OrderController.kt`, when the frame names the class).
|
||||
* package directory, `com/acme/OrderController.kt`, when the frame names the class);
|
||||
* `rubyTest`: a Minitest test with no file in its output — `className` is its class, `path` its
|
||||
* method name — found by searching the project (`rubyTests.ts`).
|
||||
*/
|
||||
resolve: "path" | "name";
|
||||
resolve: "path" | "name" | "rubyTest";
|
||||
/** JVM frames: the class's binary name (`com.acme.OrderController$Inner`). */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
import { call, commands, isTauri, nextRequestId } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { editorService, notifications } from "@/workbench/contributions";
|
||||
import { streamSearch } from "@/platform/textSearch";
|
||||
import { joinPath, type FileTarget } from "./links";
|
||||
import { findRubyTest } from "./rubyTests";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
@@ -90,8 +92,45 @@ export async function resolveTarget(target: FileTarget, cwd: string): Promise<st
|
||||
return (await findByName(target)) ?? (target.className ? findLibraryClass(target.className) : null);
|
||||
}
|
||||
|
||||
/** Regular-expression search over the project's Ruby files (or the given ones). */
|
||||
async function searchRuby(pattern: string, files?: string[]) {
|
||||
const out: import("@/platform/ipc").FileMatches[] = [];
|
||||
await streamSearch(
|
||||
nextRequestId(),
|
||||
{
|
||||
pattern,
|
||||
isRegex: true,
|
||||
caseSensitive: true,
|
||||
wholeWord: false,
|
||||
include: files ? [] : ["*.rb"],
|
||||
exclude: [],
|
||||
scope: files ? { kind: "files", paths: files } : { kind: "project" },
|
||||
maxMatches: 500,
|
||||
},
|
||||
(f) => out.push(f),
|
||||
);
|
||||
return out.sort((a, b) => a.relPath.localeCompare(b.relPath));
|
||||
}
|
||||
|
||||
export async function openTarget(target: FileTarget, cwd: string, focus = true): Promise<void> {
|
||||
if (!isTauri()) return;
|
||||
if (target.resolve === "rubyTest") {
|
||||
const site = target.className
|
||||
? await findRubyTest(searchRuby, target.className, target.path).catch(() => null)
|
||||
: null;
|
||||
if (!site) {
|
||||
notifications().notify({
|
||||
group: "run",
|
||||
level: "warning",
|
||||
title: "File not found",
|
||||
body: `${target.className ?? ""}#${target.path}`,
|
||||
key: "run-link",
|
||||
});
|
||||
return;
|
||||
}
|
||||
await editorService().openFile(site.path, { selection: { line: site.line - 1, col: 0 }, focus });
|
||||
return;
|
||||
}
|
||||
const path = await resolveTarget(target, cwd);
|
||||
if (!path && target.className) {
|
||||
// A library or JDK frame without sources: nothing to open, which is no error.
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/platform/projectRoots", () => ({
|
||||
currentRoots: () => [
|
||||
{ path: "lib", kind: "source", detectedFrom: null },
|
||||
{ path: "test", kind: "test", detectedFrom: null },
|
||||
{ path: "spec", kind: "test", detectedFrom: null },
|
||||
{ path: "", kind: "source", detectedFrom: null },
|
||||
],
|
||||
}));
|
||||
|
||||
import type { RunConfig } from "@/platform/ipc";
|
||||
import { produceContext, produceDirectory, produceFile, type ToolEnv } from "./producers";
|
||||
|
||||
const env: ToolEnv = {
|
||||
tool: (n) => (n === "ruby" ? { path: "/bin/ruby", version: null } : null),
|
||||
compileDir: "/tmp/openidea-run",
|
||||
};
|
||||
const input = (path: string) => ({ path, root: "/work/app", configs: [], content: undefined, line: undefined });
|
||||
|
||||
describe("Ruby file run", () => {
|
||||
it("puts Sources roots on the load path", () => {
|
||||
const p = produceContext(input("/work/app/test/integration/a_test.rb"), env);
|
||||
expect(p?.config?.args).toEqual(["-Ilib", "test/integration/a_test.rb"]);
|
||||
});
|
||||
|
||||
it("adds none for files outside the project", () => {
|
||||
const p = produceContext(input("/elsewhere/a.rb"), env);
|
||||
expect(p?.config?.args).toEqual(["a.rb"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ruby directory run", () => {
|
||||
const cfg = (over: Partial<RunConfig> & { id: string; kind: string }): RunConfig => ({
|
||||
name: over.id,
|
||||
command: "bundle",
|
||||
args: [],
|
||||
cwd: null,
|
||||
env: {},
|
||||
detected: true,
|
||||
isTest: true,
|
||||
debugAdapter: null,
|
||||
...over,
|
||||
});
|
||||
const dir = (path: string, configs: RunConfig[]) =>
|
||||
produceDirectory({ path: `/work/app/${path}`, root: "/work/app", configs });
|
||||
|
||||
it("RSpec on a nested folder", () => {
|
||||
const p = dir("spec/models/user", [cfg({ id: "rspec", kind: "rspec", args: ["exec", "rspec"] })]);
|
||||
expect(p?.config?.args).toEqual(["exec", "rspec", "spec/models/user"]);
|
||||
expect(p?.name).toBe("Tests in 'user'");
|
||||
});
|
||||
|
||||
it("rake test with a TEST glob", () => {
|
||||
const p = dir("test/unit", [cfg({ id: "rake:test", kind: "rake", args: ["exec", "rake", "test"] })]);
|
||||
expect(p?.config?.args).toEqual(["exec", "rake", "test", "TEST=test/unit/**/*_test.rb,test/unit/**/test_*.rb"]);
|
||||
});
|
||||
|
||||
it("the Minitest runner with Sources roots and the folder", () => {
|
||||
const base = cfg({ id: "minitest", kind: "ruby", args: ["exec", "ruby", "-Itest", "-e", "SCRIPT", "test"] });
|
||||
const p = dir("test/unit", [base]);
|
||||
expect(p?.config?.args).toEqual(["exec", "ruby", "-Itest", "-Ilib", "-e", "SCRIPT", "test/unit"]);
|
||||
});
|
||||
|
||||
it("the test root itself", () => {
|
||||
const p = dir("test", [cfg({ id: "rspec", kind: "rspec", args: ["exec", "rspec"] })]);
|
||||
expect(p?.config?.args).toEqual(["exec", "rspec", "test"]);
|
||||
});
|
||||
|
||||
it("nothing for non-test folders or projects without a Ruby runner", () => {
|
||||
expect(dir("lib/unit", [cfg({ id: "rspec", kind: "rspec" })])).toBeNull();
|
||||
expect(dir("other/unit", [cfg({ id: "rspec", kind: "rspec" })])).toBeNull();
|
||||
expect(dir("test/unit", [])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ruby test files", () => {
|
||||
const rake = {
|
||||
id: "rake:test",
|
||||
name: "rake test",
|
||||
kind: "rake",
|
||||
command: "bundle",
|
||||
args: ["exec", "rake", "test"],
|
||||
cwd: null,
|
||||
env: {},
|
||||
detected: true,
|
||||
isTest: true,
|
||||
debugAdapter: null,
|
||||
} as RunConfig;
|
||||
const minitest = {
|
||||
...rake,
|
||||
id: "minitest",
|
||||
name: "minitest",
|
||||
kind: "ruby",
|
||||
command: "ruby",
|
||||
args: ["-Ilib", "-Itest", "-e", "RUNNER", "test"],
|
||||
} as RunConfig;
|
||||
const file = (path: string, configs: RunConfig[]) =>
|
||||
produceFile({ path: `/work/app/${path}`, root: "/work/app", configs, content: "" }, env);
|
||||
|
||||
it("test_*.rb is a test file: rake test with TEST=<file>, reported per test", () => {
|
||||
const p = file("test/unit/test_decide.rb", [rake]);
|
||||
expect(p?.config).toMatchObject({ isTest: true, args: ["exec", "rake", "test", "TEST=test/unit/test_decide.rb"] });
|
||||
});
|
||||
|
||||
it("without a rake test task the detected Minitest runner loads the file", () => {
|
||||
const p = file("test/unit/test_decide.rb", [minitest]);
|
||||
expect(p?.config).toMatchObject({
|
||||
isTest: true,
|
||||
kind: "ruby",
|
||||
args: ["-Ilib", "-Itest", "-e", "RUNNER", "test/unit/test_decide.rb"],
|
||||
});
|
||||
});
|
||||
|
||||
it("other scripts stay plain runs", () => {
|
||||
expect(file("lib/tool.rb", [rake])?.config?.isTest).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -596,6 +596,10 @@ function simple(
|
||||
return { name: s.file, scope: "file", config: fileConfig(s, kind, t.command, args(t.command, t.name), debugAdapter) };
|
||||
}
|
||||
|
||||
/** `-I<dir>` for each Sources root of the project (the project root itself is already the cwd). */
|
||||
const rubyLoadPath = (roots: readonly { path: string; kind: string }[]): string[] =>
|
||||
roots.filter((r) => r.kind === "source" && r.path !== "").map((r) => `-I${r.path}`);
|
||||
|
||||
const hasGoMain = (content?: string) => content === undefined || /^\s*package\s+main\b/m.test(content);
|
||||
const hasRustMain = (content?: string) =>
|
||||
content === undefined || /^(?:pub\s+)?(?:async\s+)?fn\s+main\s*\(/m.test(content);
|
||||
@@ -798,6 +802,8 @@ export function produceFile(input: ContextInput, env: ToolEnv): Produced | null
|
||||
};
|
||||
}
|
||||
case "ruby":
|
||||
// Sources roots go on the load path (`-Ilib`), as `rake test` does; paths are project-relative.
|
||||
return simple(lang, s, env, lang, () => [...(s.inProject ? rubyLoadPath(currentRoots()) : []), s.arg]);
|
||||
case "php":
|
||||
case "perl":
|
||||
case "lua":
|
||||
@@ -966,7 +972,7 @@ function elementProduction(input: ContextInput, env: ToolEnv, target: RunTarget)
|
||||
export function produceContext(input: ContextInput, env: ToolEnv): Produced | null {
|
||||
if (input.content !== undefined && input.line !== undefined) {
|
||||
const targets = fileTargets(input, input.content.slice(0, 400_000));
|
||||
const inner = innermostTarget(input.content, targets, input.line, /\.py$/.test(input.path));
|
||||
const inner = innermostTarget(input.content, targets, input.line, /\.(py|rb)$/.test(input.path));
|
||||
if (inner) {
|
||||
const p = elementProduction(input, env, inner);
|
||||
if (p) return p;
|
||||
@@ -991,7 +997,7 @@ export function produceContextChoices(input: ContextInput, env: ToolEnv): Produc
|
||||
let element: RunTarget | null = null;
|
||||
if (produced.scope === "element") {
|
||||
const targets = fileTargets(input, (input.content ?? "").slice(0, 400_000));
|
||||
element = innermostTarget(input.content ?? "", targets, input.line ?? 0, /\.py$/.test(input.path));
|
||||
element = innermostTarget(input.content ?? "", targets, input.line ?? 0, /\.(py|rb)$/.test(input.path));
|
||||
if (!element || element.kind === "main" || element.kind === "generate") return [produced];
|
||||
} else if (!produced.config.isTest) {
|
||||
return [produced];
|
||||
@@ -1021,6 +1027,33 @@ function cmakeTargets(input: ContextInput): RunConfig[] {
|
||||
|
||||
const TEST_DIR = /(^|\/)(tests?|__tests__|specs?|test_?\w*)$/;
|
||||
|
||||
/**
|
||||
* The tests of a Ruby folder: RSpec on the folder, `rake test` with `TEST=<glob>`, or the
|
||||
* detected Minitest runner given the folder (Sources roots on its load path).
|
||||
*/
|
||||
function rubyDirectory(
|
||||
closest: (kinds: string[]) => RunConfig | undefined,
|
||||
relToCwd: (c: RunConfig) => string,
|
||||
make: (base: RunConfig, label: string, args: string[]) => Produced,
|
||||
label: string,
|
||||
): Produced | null {
|
||||
const rspec = closest(["rspec"]);
|
||||
if (rspec) return make(rspec, label, [...rspec.args, relToCwd(rspec)]);
|
||||
const rake = closest(["rake"]);
|
||||
if (rake && rake.args.at(-1) === "test") {
|
||||
// Rake splits `TEST` at commas into patterns, so no braces: two globs.
|
||||
const dir = relToCwd(rake);
|
||||
return make(rake, label, [...rake.args, `TEST=${dir}/**/*_test.rb,${dir}/**/test_*.rb`]);
|
||||
}
|
||||
const minitest = closest(["ruby"]);
|
||||
if (!minitest) return null;
|
||||
const at = minitest.args.indexOf("-e");
|
||||
const flags = rubyLoadPath(currentRoots()).filter((f) => !minitest.args.includes(f));
|
||||
const args = [...minitest.args.slice(0, -1)];
|
||||
args.splice(at < 0 ? args.length : at, 0, ...flags);
|
||||
return make(minitest, label, [...args, relToCwd(minitest)]);
|
||||
}
|
||||
|
||||
export function produceDirectory(input: ContextInput): Produced | null {
|
||||
const { root } = input;
|
||||
if (!root || !isProjectPath(input) || input.path === root) return null;
|
||||
@@ -1029,8 +1062,8 @@ export function produceDirectory(input: ContextInput): Produced | null {
|
||||
const jvmRoot = /^(?:(.*)\/)?src\/test(?:\/(java|kotlin|scala|groovy))?$/.exec(rel);
|
||||
const jvmPackage = /^(?:(.*)\/)?src\/test\/(?:java|kotlin|scala|groovy)\/(.+)$/.exec(rel);
|
||||
const marked = isTestPath(currentRoots(), rel);
|
||||
if (!TEST_DIR.test(rel) && !jvmRoot && !jvmPackage && !marked && !/(^|\/)src\/test(\/|$)/.test(rel)) return null;
|
||||
const tests = input.configs.filter((c) => c.isTest);
|
||||
if (!TEST_DIR.test(rel) && !jvmRoot && !jvmPackage && !marked && !/(^|\/)src\/test(\/|$)/.test(rel)) return null;
|
||||
const closest = (kinds: string[]) => {
|
||||
const cands = tests.filter((c) => kinds.includes(c.kind));
|
||||
const score = (c: RunConfig) => {
|
||||
@@ -1071,6 +1104,8 @@ export function produceDirectory(input: ContextInput): Produced | null {
|
||||
}
|
||||
}
|
||||
const js = closest(["npm", "bun", "deno"]);
|
||||
const ruby = marked && !js && rubyDirectory(closest, relToCwd, make, testsIn);
|
||||
if (ruby) return ruby;
|
||||
const py = closest(["pytest"]);
|
||||
const django = closest(["django-test"]);
|
||||
const go = closest(["go"]);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { RunConfig } from "@/platform/ipc";
|
||||
import { detectRunTargets, targetConfig, targetLanguage } from "./targets";
|
||||
|
||||
const SRC = `require_relative "../test_helper"
|
||||
|
||||
module Outer
|
||||
class InnerTest < Minitest::Test
|
||||
def test_one = assert true
|
||||
|
||||
def test_two?
|
||||
assert true
|
||||
end
|
||||
|
||||
def helper = 1
|
||||
end
|
||||
|
||||
class Helper
|
||||
def test_not_a_test_class = 1
|
||||
end
|
||||
end
|
||||
|
||||
class TopTest < ActiveSupport::TestCase
|
||||
test "does a thing" do
|
||||
assert true
|
||||
end
|
||||
end
|
||||
|
||||
module Empty
|
||||
class Plain
|
||||
end
|
||||
end
|
||||
`;
|
||||
|
||||
const cfg = (over: Partial<RunConfig> & { id: string; kind: string }): RunConfig => ({
|
||||
name: over.id,
|
||||
command: "bundle",
|
||||
args: [],
|
||||
cwd: null,
|
||||
env: {},
|
||||
detected: true,
|
||||
isTest: true,
|
||||
debugAdapter: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("Ruby run targets", () => {
|
||||
const targets = detectRunTargets(SRC, "test/unit/test_outer.rb");
|
||||
const find = (name: string) => targets.find((t) => t.name === name)!;
|
||||
|
||||
it("is a target language", () => expect(targetLanguage("a.rb")).toBe("ruby"));
|
||||
|
||||
it("finds test classes, the modules holding them, and their test methods", () => {
|
||||
expect(targets.map((t) => [t.kind, t.name, t.line])).toEqual([
|
||||
["suite", "Outer", 2],
|
||||
["suite", "Outer::InnerTest", 3],
|
||||
["test", "Outer::InnerTest#test_one", 4],
|
||||
["test", "Outer::InnerTest#test_two?", 6],
|
||||
["suite", "TopTest", 18],
|
||||
["test", "TopTest#test_does_a_thing", 19],
|
||||
]);
|
||||
expect(find("Outer").rubyModule).toBe(true);
|
||||
expect(find("Outer::InnerTest").path).toEqual(["Outer::InnerTest"]);
|
||||
expect(find("Outer::InnerTest#test_one").path).toEqual(["Outer::InnerTest", "test_one"]);
|
||||
});
|
||||
|
||||
it("leaves modules without test classes, non-test classes and non-test files alone", () => {
|
||||
expect(targets.some((t) => t.name === "Empty" || t.name.includes("Helper"))).toBe(false);
|
||||
expect(detectRunTargets(SRC, "lib/outer.rb")).toEqual([]);
|
||||
expect(detectRunTargets(SRC, "spec/outer_spec.rb")).toEqual([]);
|
||||
});
|
||||
|
||||
describe("run configurations", () => {
|
||||
const rake = cfg({ id: "rake:test", kind: "rake", args: ["exec", "rake", "test"] });
|
||||
const minitest = cfg({ id: "minitest", kind: "ruby", command: "ruby", args: ["-Ilib", "-e", "RUNNER", "test"] });
|
||||
const run = (t: string, base: RunConfig) => targetConfig(find(t), "test/unit/test_outer.rb", [base], SRC)?.args;
|
||||
|
||||
it("one test by its exact name, through rake and through the Minitest runner", () => {
|
||||
expect(run("Outer::InnerTest#test_one", rake)).toEqual([
|
||||
"exec",
|
||||
"rake",
|
||||
"test",
|
||||
"TEST=test/unit/test_outer.rb",
|
||||
"TESTOPTS=--name=Outer::InnerTest#test_one",
|
||||
]);
|
||||
expect(run("Outer::InnerTest#test_one", minitest)).toEqual([
|
||||
"-Ilib",
|
||||
"-e",
|
||||
"RUNNER",
|
||||
"test/unit/test_outer.rb",
|
||||
"--name=Outer::InnerTest#test_one",
|
||||
]);
|
||||
});
|
||||
|
||||
it("a class runs its own tests, a module the tests of the classes inside it", () => {
|
||||
expect(run("Outer::InnerTest", minitest)!.at(-1)).toBe("--name=/^Outer::InnerTest#/");
|
||||
expect(run("Outer", minitest)!.at(-1)).toBe("--name=/^Outer::/");
|
||||
expect(run("Outer", rake)!.at(-1)).toBe("TESTOPTS=--name=/^Outer::/");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { FileMatches } from "@/platform/ipc";
|
||||
import { findRubyTest, type RubySearch } from "./rubyTests";
|
||||
|
||||
const file = (path: string, ...lines: number[]): FileMatches => ({
|
||||
path,
|
||||
relPath: path.slice(1),
|
||||
matches: lines.map((line) => ({ line, text: "", ranges: [], textOffset: 0 })),
|
||||
});
|
||||
|
||||
/** A search over fixed hits: the first matching pattern fragment wins. */
|
||||
function fake(hits: Record<string, FileMatches[]>): RubySearch & { calls: [string, string[] | undefined][] } {
|
||||
const calls: [string, string[] | undefined][] = [];
|
||||
const fn = (async (pattern: string, files?: string[]) => {
|
||||
calls.push([pattern, files]);
|
||||
const key = Object.keys(hits).find((k) => pattern.includes(k));
|
||||
const found = key ? hits[key]! : [];
|
||||
return files ? found.filter((f) => files.includes(f.path)) : found;
|
||||
}) as RubySearch & { calls: [string, string[] | undefined][] };
|
||||
fn.calls = calls;
|
||||
return fn;
|
||||
}
|
||||
|
||||
describe("finding a Minitest test's source", () => {
|
||||
it("the def inside the file that declares the class", async () => {
|
||||
const search = fake({
|
||||
"(class|module)": [file("/p/test/a_test.rb", 0), file("/p/test/b_test.rb", 0)],
|
||||
"def\\s+test_init": [file("/p/test/a_test.rb", 11), file("/p/test/b_test.rb", 4)],
|
||||
});
|
||||
// Only b declares TestB: its def wins even though a's file sorts first.
|
||||
const owners = fake({
|
||||
"(class|module)": [file("/p/test/b_test.rb", 0)],
|
||||
"def\\s+test_init": [file("/p/test/a_test.rb", 11), file("/p/test/b_test.rb", 4)],
|
||||
});
|
||||
expect(await findRubyTest(owners, "TestB", "test_initialize")).toEqual({ path: "/p/test/b_test.rb", line: 5 });
|
||||
expect(await findRubyTest(search, "TestA", "test_initialize")).toEqual({ path: "/p/test/a_test.rb", line: 12 });
|
||||
});
|
||||
|
||||
it("looks for the last segment of a namespaced class", async () => {
|
||||
const search = fake({
|
||||
InnerTest: [file("/p/test/n_test.rb", 3)],
|
||||
"def\\s+test_nested": [file("/p/test/n_test.rb", 4)],
|
||||
});
|
||||
expect(await findRubyTest(search, "Outer::InnerTest", "test_nested")).toEqual({
|
||||
path: "/p/test/n_test.rb",
|
||||
line: 5,
|
||||
});
|
||||
expect(search.calls[0]![0]).not.toContain("Outer");
|
||||
});
|
||||
|
||||
it("spec-style tests: the it block of the describe, underscores for spaces", async () => {
|
||||
const search = fake({
|
||||
describe: [file("/p/spec/x_test.rb", 0)],
|
||||
"does[ _]a[ _]thing": [file("/p/spec/x_test.rb", 6)],
|
||||
});
|
||||
expect(await findRubyTest(search, "Spec style", "test_0001_does_a_thing")).toEqual({
|
||||
path: "/p/spec/x_test.rb",
|
||||
line: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the class when the method is not found, and to nothing when nothing is", async () => {
|
||||
const onlyClass = fake({ "(class|module)": [file("/p/test/a_test.rb", 2)] });
|
||||
expect(await findRubyTest(onlyClass, "TestA", "test_dynamic")).toEqual({ path: "/p/test/a_test.rb", line: 3 });
|
||||
expect(await findRubyTest(fake({}), "TestA", "test_x")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Where a Minitest test is defined. Minitest's output names `Class#test_name` and no file for tests that
|
||||
* pass, so the tree finds the source by searching the project: the class, then the `def` in its file.
|
||||
* Spec-style tests (`describe "Thing" do it "does x"`) are reported as `Thing#test_0001_does_x`.
|
||||
*/
|
||||
import type { FileMatches } from "@/platform/ipc";
|
||||
|
||||
/** Runs a regular-expression search over the project's Ruby files, or over `files` when given. */
|
||||
export type RubySearch = (pattern: string, files?: string[]) => Promise<FileMatches[]>;
|
||||
|
||||
export interface RubyTestSite {
|
||||
path: string;
|
||||
/** 1-based. */
|
||||
line: number;
|
||||
}
|
||||
|
||||
const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const SPEC_TEST = /^test_\d+_(.+)$/;
|
||||
|
||||
const first = (hits: FileMatches[], file?: string) => {
|
||||
const hit = file ? hits.find((h) => h.path === file) : hits[0];
|
||||
return hit?.matches[0] ? { path: hit.path, line: hit.matches[0].line + 1 } : null;
|
||||
};
|
||||
|
||||
export async function findRubyTest(search: RubySearch, className: string, test: string): Promise<RubyTestSite | null> {
|
||||
const spec = SPEC_TEST.exec(test);
|
||||
// `Outer::InnerTest` is declared as `class InnerTest` inside `module Outer`, or as written.
|
||||
const last = className.split("::").pop()!;
|
||||
const declared = spec
|
||||
? `^\\s*(describe|context)\\s*\\(?\\s*(["'])${escape(className)}\\2`
|
||||
: `^\\s*(class|module)\\s+([A-Z]\\w*::)*${escape(last)}\\b`;
|
||||
const owners = await search(declared);
|
||||
const files = owners.map((o) => o.path);
|
||||
const inOwners = (pattern: string) => (files.length ? search(pattern, files) : search(pattern));
|
||||
const body = spec
|
||||
? // Spec descriptions lose their spaces to underscores in the method name.
|
||||
`^\\s*it\\s*\\(?\\s*(["'])${escape(spec[1]!).replace(/_/g, "[ _]")}\\1`
|
||||
: `^\\s*def\\s+${escape(test)}\\b`;
|
||||
const hits = await inOwners(body);
|
||||
// The same method name is common (`test_initialize`): prefer the file that declares the class.
|
||||
const site =
|
||||
first(
|
||||
hits,
|
||||
files.find((f) => hits.some((h) => h.path === f)),
|
||||
) ?? first(hits);
|
||||
return site ?? first(owners);
|
||||
}
|
||||
@@ -10,12 +10,27 @@ import { commandName } from "./commandName";
|
||||
|
||||
const on = (v: string | undefined) => v === "true";
|
||||
|
||||
const RUBY_TOOLS = new Set(["ruby", "bundle", "rake", "rspec"]);
|
||||
|
||||
/**
|
||||
* The launcher for `want` (`bundle` or the tool itself) from the Launcher field. A field holding
|
||||
* the other Ruby tool of the same interpreter (`/v/bin/ruby` when Bundler is ticked) means its
|
||||
* sibling in that directory; anything else (a binstub, `spring`) is the user's own and stays.
|
||||
*/
|
||||
function launcher(command: string | undefined, want: string): string {
|
||||
const given = command?.trim();
|
||||
if (!given) return want;
|
||||
const current = commandName(given);
|
||||
if (current === want || !RUBY_TOOLS.has(current)) return given;
|
||||
const dir = /^.*[/\\]/.exec(given)?.[0] ?? "";
|
||||
return dir + want + (/\.exe$/i.test(given) ? ".exe" : "");
|
||||
}
|
||||
|
||||
/** `bundle exec <tool>` or `<tool>`, launched through `params.command` when one is set. */
|
||||
function tool(p: Readonly<Record<string, string>>, name: string): { command: string; args: string[] } {
|
||||
const command = p.command?.trim();
|
||||
return on(p.bundler)
|
||||
? { command: command || "bundle", args: ["exec", name] }
|
||||
: { command: command || name, args: [] };
|
||||
? { command: launcher(p.command, "bundle"), args: ["exec", name] }
|
||||
: { command: launcher(p.command, name), args: [] };
|
||||
}
|
||||
|
||||
/** Splits `bundle exec <tool> rest…` / `<tool> rest…` into the launcher, the Bundler flag and `rest`. */
|
||||
|
||||
@@ -225,6 +225,24 @@ describe("configuration types", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("ticking Bundler swaps the interpreter launcher for its bundle sibling, and back", () => {
|
||||
const abs = "/v/ruby/bin/ruby";
|
||||
const run = cfg("r", { kind: "ruby", command: abs, args: ["-Ilib", "test/a_test.rb"] });
|
||||
expect(withParams(run, { ...formParams(run)!, bundler: "true" })).toMatchObject({
|
||||
command: "/v/ruby/bin/bundle",
|
||||
args: ["exec", "ruby", "-Ilib", "test/a_test.rb"],
|
||||
});
|
||||
const bundled = cfg("r", { kind: "ruby", command: "/v/ruby/bin/bundle", args: ["exec", "ruby", "a.rb"] });
|
||||
expect(withParams(bundled, { ...formParams(bundled)!, bundler: "" })).toMatchObject({
|
||||
command: "/v/ruby/bin/ruby",
|
||||
args: ["a.rb"],
|
||||
});
|
||||
// A launcher of the user's own (binstub, spring) is kept.
|
||||
expect(withParams(run, { ...formParams(run)!, bundler: "true", command: "bin/spring" })).toMatchObject({
|
||||
command: "bin/spring",
|
||||
});
|
||||
});
|
||||
|
||||
it("ruby types keep option values with their options and the argument order as written", () => {
|
||||
const ruby = (args: string[]) => formParams(cfg("r", { kind: "ruby", command: "ruby", args }));
|
||||
expect(ruby(["-I", "lib", "-w", "bin/app", "--port", "3000"])).toEqual({
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} from "./testing";
|
||||
import { GoTestOutput } from "./testing/go";
|
||||
import { moveToReportedClasses } from "./testing/jvm";
|
||||
import { RubyTestOutput } from "./testing/ruby";
|
||||
import { TeamcityOutput } from "./testing/teamcity";
|
||||
import type { TestStdout } from "./testing/types";
|
||||
import { recordTestStates } from "./testing/states";
|
||||
@@ -87,7 +88,7 @@ interface SessionRuntime {
|
||||
console: ConsoleModel;
|
||||
tests: TestResults | null;
|
||||
parser: TestOutputParser | null;
|
||||
/** Machine-readable stdout (`go test -json`, `--teamcity`): fed to the parser, shown decoded. */
|
||||
/** Machine-readable stdout (`go test -json`, `--teamcity`, the Ruby reporter): fed to the parser, shown decoded. */
|
||||
stdout: TestStdout | null;
|
||||
queue: OutputChunk[];
|
||||
/** rAF id while the page is visible, timeout id while hidden (rAF pauses in background tabs). */
|
||||
@@ -567,13 +568,16 @@ function newRuntime(config: RunConfig): SessionRuntime {
|
||||
if (tests && parser) tests.apply(parser.feed(line));
|
||||
};
|
||||
const framework = tests && parser ? frameworkFor(config) : null;
|
||||
// PHPUnit `--teamcity` and Gradle's test init script report through service messages.
|
||||
// PHPUnit `--teamcity` and Gradle's test init script report through service messages, Minitest through
|
||||
// the IDE's own reporter (RSpec has none: its progress dots must show as they come).
|
||||
const stdout =
|
||||
framework === "go"
|
||||
? new GoTestOutput(feed)
|
||||
: framework === "phpunit" || framework === "jvm"
|
||||
? new TeamcityOutput(feed)
|
||||
: null;
|
||||
: framework === "ruby" && config.kind !== "rspec"
|
||||
? new RubyTestOutput(feed)
|
||||
: null;
|
||||
const runtime: SessionRuntime = {
|
||||
stdout,
|
||||
tests,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Run targets inside a file, for the gutter ▶ icons and "Run test" code lenses:
|
||||
* `fn main`, `#[test]` functions and `mod tests`, JS/TS `test`/`it`/`describe`,
|
||||
* pytest `def test_*` / `class Test*`, unittest `TestCase` subclasses, Go `func TestX(t *testing.T)`,
|
||||
* JUnit `@Test`.
|
||||
* JUnit `@Test`, Minitest / Test::Unit classes, modules holding them and `def test_*`.
|
||||
* Line-based regex detection (cheap, no parser), plus the run configuration for
|
||||
* one target derived from the project's detected test configurations.
|
||||
*/
|
||||
@@ -34,12 +34,14 @@ export interface RunTarget {
|
||||
spring?: boolean;
|
||||
/** Python: a unittest `TestCase` subclass or one of its methods (what Django's runner can run). */
|
||||
unittest?: boolean;
|
||||
/** Ruby: a `module` suite, which runs the tests of every class declared inside it. */
|
||||
rubyModule?: boolean;
|
||||
}
|
||||
|
||||
/** Above this size we skip detection (generated / minified files). */
|
||||
export const MAX_SCAN = 400_000;
|
||||
|
||||
type Lang = "rust" | "js" | "python" | "php" | "go" | "jvm" | null;
|
||||
type Lang = "rust" | "js" | "python" | "php" | "go" | "jvm" | "ruby" | null;
|
||||
|
||||
export function targetLanguage(relPath: string): Lang {
|
||||
if (/\.rs$/.test(relPath)) return "rust";
|
||||
@@ -48,6 +50,7 @@ export function targetLanguage(relPath: string): Lang {
|
||||
if (/\.php$/.test(relPath)) return "php";
|
||||
if (/\.go$/.test(relPath)) return "go";
|
||||
if (/\.(java|kt|kts|scala|groovy)$/.test(relPath)) return "jvm";
|
||||
if (/\.rb$/.test(relPath)) return "ruby";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -211,6 +214,53 @@ function pythonTargets(lines: string[]): RunTarget[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A class that holds Minitest / Test::Unit tests: by its superclass or its name. */
|
||||
const isRubyTestClass = (name: string, superclass: string | undefined) =>
|
||||
/(Test|TestCase|Spec)$/.test(superclass ?? "") || /Tests?(Case)?$/.test(name.split("::").pop()!);
|
||||
|
||||
/**
|
||||
* Minitest / Test::Unit: test classes, the modules that hold them (run together) and their
|
||||
* `def test_*` methods (ActiveSupport's `test "does x" do` is `test_does_x`). Frames are tracked
|
||||
* by indentation, as Python's are. Results name a class by its full name (`Outer::InnerTest`).
|
||||
*/
|
||||
function rubyTargets(lines: string[]): RunTarget[] {
|
||||
const frames: Array<{ indent: number; name: string; test: boolean; at: number; hasTests: boolean }> = [];
|
||||
const out: Array<RunTarget & { frame?: (typeof frames)[number] }> = [];
|
||||
const fullName = () => frames.map((f) => f.name).join("::");
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]!;
|
||||
if (!line.trim() || /^\s*#/.test(line)) continue;
|
||||
const indent = indentOf(line);
|
||||
while (frames.length && frames[frames.length - 1]!.indent >= indent) frames.pop();
|
||||
const decl = /^\s*(class|module)\s+([A-Z]\w*(?:::[A-Z]\w*)*)(?:\s*<\s*([\w:]+))?/.exec(line);
|
||||
if (decl) {
|
||||
const test = decl[1] === "class" && isRubyTestClass(decl[2]!, decl[3]);
|
||||
const frame = { indent, name: decl[2]!, test, at: i, hasTests: false };
|
||||
frames.push(frame);
|
||||
out.push({
|
||||
line: i,
|
||||
kind: "suite",
|
||||
name: fullName(),
|
||||
path: [fullName()],
|
||||
...(decl[1] === "module" ? { rubyModule: true } : {}),
|
||||
frame,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const owner = frames[frames.length - 1];
|
||||
if (!owner?.test) continue;
|
||||
const method =
|
||||
/^\s*def\s+(test_\w*[?!]?)/.exec(line)?.[1] ??
|
||||
(/^\s*test\s*\(?\s*(["'])(.+?)\1/.exec(line)?.[2] ?? "").replace(/\s+/g, "_").replace(/^(?=.)/, "test_");
|
||||
if (method && method !== "test_") {
|
||||
for (const f of frames) f.hasTests = true;
|
||||
out.push({ line: i, kind: "test", name: `${fullName()}#${method}`, path: [fullName(), method] });
|
||||
}
|
||||
}
|
||||
// A class or module that holds no tests is noise.
|
||||
return out.filter((t) => t.kind !== "suite" || t.frame?.hasTests).map(({ frame: _frame, ...t }) => t);
|
||||
}
|
||||
|
||||
/** `go test` reports subtest names with spaces as `_` (`t.Run("zero case")` → `TestX/zero_case`). */
|
||||
export const goSubtestName = (name: string) => name.replace(/\s/g, "_");
|
||||
|
||||
@@ -367,6 +417,9 @@ export function detectRunTargets(text: string, relPath: string, django = false):
|
||||
return goTargets(lines).filter((t) => t.kind === "main" || t.kind === "generate" || /_test\.go$/.test(relPath));
|
||||
case "jvm":
|
||||
return /\.(java|kts?)$/.test(relPath) ? javaKotlinTargets(text, relPath) : jvmTargets(lines, relPath);
|
||||
case "ruby":
|
||||
// RSpec files (`spec/`) are not Minitest.
|
||||
return /(^|\/)test_[^/]*\.rb$|_test\.rb$|(^|\/)tests?\//.test(relPath) ? rubyTargets(lines) : [];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
@@ -456,6 +509,17 @@ export function targetConfig(
|
||||
};
|
||||
return result;
|
||||
}
|
||||
case "rake":
|
||||
case "ruby": {
|
||||
// Minitest's `--name=`: `Class#test_x` names one test exactly, a regexp (no `$`, which a shell would
|
||||
// eat) the rest. The `=` form is one argument: rake's test loader would `require` the value of `-n x`.
|
||||
const full = target.path[0]!;
|
||||
const pattern =
|
||||
target.kind === "test" ? `${full}#${target.path[1]}` : target.rubyModule ? `/^${full}::/` : `/^${full}#/`;
|
||||
return file.kind === "rake"
|
||||
? named([...file.args, `TESTOPTS=--name=${pattern}`])
|
||||
: named([...file.args, `--name=${pattern}`]);
|
||||
}
|
||||
case "cargo": {
|
||||
const path = target.path.join("::");
|
||||
return named(target.kind === "suite" ? [...file.args, "--", `${path}::`] : [...file.args, "--", path, "--exact"]);
|
||||
|
||||
@@ -6,13 +6,14 @@ import { jsParser } from "./js";
|
||||
import { jvmParser } from "./jvm";
|
||||
import { phpunitParser, withTeamcity } from "./phpunit";
|
||||
import { pytestParser } from "./pytest";
|
||||
import { rubyParser } from "./ruby";
|
||||
import { unittestParser } from "./unittest";
|
||||
import type { TestEvent, TestOutputParser } from "./types";
|
||||
|
||||
export type { TestEvent, TestOutputParser, TestStatus } from "./types";
|
||||
export { parseDuration } from "./types";
|
||||
export { TestResults, nodeStatus, type TestNode } from "./results";
|
||||
export { cargoParser, goParser, jsParser, jvmParser, phpunitParser, pytestParser, unittestParser };
|
||||
export { cargoParser, goParser, jsParser, jvmParser, phpunitParser, pytestParser, rubyParser, unittestParser };
|
||||
|
||||
/** Runs several parsers side by side (unknown tool: e.g. `make test`). */
|
||||
export function combinedParser(parsers: TestOutputParser[]): TestOutputParser {
|
||||
@@ -22,7 +23,7 @@ export function combinedParser(parsers: TestOutputParser[]): TestOutputParser {
|
||||
};
|
||||
}
|
||||
|
||||
export type Framework = "cargo" | "js" | "pytest" | "unittest" | "phpunit" | "go" | "jvm" | "auto";
|
||||
export type Framework = "cargo" | "js" | "pytest" | "unittest" | "phpunit" | "go" | "jvm" | "ruby" | "auto";
|
||||
|
||||
export function frameworkFor(config: Pick<RunConfig, "kind" | "command" | "args">): Framework {
|
||||
const cmd = [config.command, ...config.args].join(" ");
|
||||
@@ -47,6 +48,10 @@ export function frameworkFor(config: Pick<RunConfig, "kind" | "command" | "args"
|
||||
case "gradle":
|
||||
case "maven":
|
||||
return "jvm";
|
||||
case "ruby":
|
||||
case "rake":
|
||||
case "rspec":
|
||||
return "ruby";
|
||||
}
|
||||
if (/\bcargo\b/.test(cmd)) return "cargo";
|
||||
if (/\b(jest|vitest|npm|pnpm|yarn|bun|npx|deno)\b/.test(cmd)) return "js";
|
||||
@@ -58,6 +63,21 @@ export function frameworkFor(config: Pick<RunConfig, "kind" | "command" | "args"
|
||||
return "auto";
|
||||
}
|
||||
|
||||
/** `TESTOPTS=-v` / a trailing `-v`: Minitest prints a line per test only when verbose. */
|
||||
function rubyVerbose<T extends Pick<RunConfig, "kind" | "command" | "args">>(config: T): T {
|
||||
if (config.kind === "rspec") return config;
|
||||
if (config.kind === "rake") {
|
||||
const at = config.args.findIndex((a) => a.startsWith("TESTOPTS="));
|
||||
if (at < 0) return { ...config, args: [...config.args, "TESTOPTS=-v"] };
|
||||
// Options already given (`--name=Class#test_x`): add ours unless verbosity was chosen.
|
||||
return /(^|\s)(-v|--verbose)(\s|$)/.test(config.args[at]!)
|
||||
? config
|
||||
: { ...config, args: config.args.map((a, i) => (i === at ? `${a} -v` : a)) };
|
||||
}
|
||||
const chosen = config.args.some((a) => a === "-v" || a === "--verbose");
|
||||
return chosen ? config : { ...config, args: [...config.args, "-v"] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch arguments that make a test run reportable per test: pytest and
|
||||
* Django print only progress dots by default, so passed tests would never
|
||||
@@ -79,6 +99,7 @@ export function withTestReporting<T extends Pick<RunConfig, "kind" | "command" |
|
||||
const chosen = config.args.some((a) => /^(-v|--verbosity)(=?\d)?$/.test(a));
|
||||
return chosen ? config : { ...config, args: [...config.args, "-v", "2"] };
|
||||
}
|
||||
if (frameworkFor(config) === "ruby") return rubyVerbose(config);
|
||||
if (frameworkFor(config) !== "pytest") return config;
|
||||
const cmd = [config.command, ...config.args];
|
||||
const pytestAt = cmd.findIndex((a) => /(^|[/\\])pytest(\.exe)?$/.test(a));
|
||||
@@ -103,6 +124,8 @@ export function createTestParser(config: Pick<RunConfig, "kind" | "command" | "a
|
||||
return goParser();
|
||||
case "jvm":
|
||||
return jvmParser();
|
||||
case "ruby":
|
||||
return rubyParser();
|
||||
default:
|
||||
return combinedParser([
|
||||
cargoParser(),
|
||||
@@ -112,6 +135,7 @@ export function createTestParser(config: Pick<RunConfig, "kind" | "command" | "a
|
||||
phpunitParser(),
|
||||
goParser(),
|
||||
jvmParser(),
|
||||
rubyParser(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +128,9 @@ export class TestResults {
|
||||
}
|
||||
case "location": {
|
||||
const n = this.node(e.path);
|
||||
n.location ??= e.location;
|
||||
// The first location wins, except that a place to search for (Ruby tests) gives way to a real one.
|
||||
if (!n.location || (n.location.resolve === "rubyTest" && e.location.resolve !== "rubyTest"))
|
||||
n.location = e.location;
|
||||
break;
|
||||
}
|
||||
case "output": {
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseAll, withTestReporting, frameworkFor } from "./index";
|
||||
import { TestResults, nodeStatus } from "./results";
|
||||
import { RubyTestOutput, rubyParser } from "./ruby";
|
||||
|
||||
// Captured from `ruby -Ilib test/mixed_test.rb -v --seed 1` (Minitest 6.0.6).
|
||||
const MINITEST = `Run options: -v --seed 1
|
||||
|
||||
# Running:
|
||||
|
||||
MixedTest#test_pass = 0.00 s = .
|
||||
MixedTest#test_fail = 0.00 s = F
|
||||
MixedTest#test_skip = 0.00 s = S
|
||||
MixedTest#test_error = 0.00 s = E
|
||||
MixedTest#test_prints = hello from test
|
||||
0.00 s = .
|
||||
Spec style#test_0001_works = 0.00 s = .
|
||||
Outer::InnerTest#test_nested = 0.00 s = .
|
||||
|
||||
Finished in 0.000893s, 7838.7459 runs/s, 5599.1042 assertions/s.
|
||||
|
||||
1) Failure:
|
||||
MixedTest#test_fail [test/mixed_test.rb:5]:
|
||||
Expected: 4
|
||||
Actual: 3
|
||||
|
||||
2) Skipped:
|
||||
MixedTest#test_skip [test/mixed_test.rb:7]:
|
||||
later
|
||||
|
||||
3) Error:
|
||||
MixedTest#test_error:
|
||||
RuntimeError: boom
|
||||
test/mixed_test.rb:6:in 'MixedTest#test_error'
|
||||
|
||||
7 runs, 5 assertions, 1 failures, 1 errors, 1 skips`;
|
||||
|
||||
describe("Minitest parser", () => {
|
||||
const events = parseAll(rubyParser(), MINITEST);
|
||||
const results = new TestResults();
|
||||
results.apply(events);
|
||||
|
||||
it("reports every test under its class, with its status", () => {
|
||||
const status = (cls: string, name: string) => nodeStatus(results.get([cls, name].join("\u0000"))!);
|
||||
expect(results.roots.map((r) => r.name).sort()).toEqual(["MixedTest", "Outer::InnerTest", "Spec style"]);
|
||||
expect(status("MixedTest", "test_pass")).toBe("passed");
|
||||
expect(status("MixedTest", "test_fail")).toBe("failed");
|
||||
expect(status("MixedTest", "test_error")).toBe("failed");
|
||||
expect(status("MixedTest", "test_skip")).toBe("skipped");
|
||||
expect(status("MixedTest", "test_prints")).toBe("passed");
|
||||
expect(status("Spec style", "test_0001_works")).toBe("passed");
|
||||
expect(status("Outer::InnerTest", "test_nested")).toBe("passed");
|
||||
});
|
||||
|
||||
it("keeps a test's own output with it, and the result printed after it", () => {
|
||||
expect(events).toContainEqual({ type: "output", path: ["MixedTest", "test_prints"], line: "hello from test" });
|
||||
expect(events.filter((e) => e.type === "result" && e.path[1] === "test_prints")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("attaches the failure text and locates it", () => {
|
||||
const out = events
|
||||
.filter((e) => e.type === "output" && e.path[1] === "test_fail")
|
||||
.map((e) => (e as { line: string }).line);
|
||||
expect(out).toEqual(expect.arrayContaining(["Expected: 4", " Actual: 3"]));
|
||||
expect(events).toContainEqual({
|
||||
type: "location",
|
||||
path: ["MixedTest", "test_fail"],
|
||||
location: { path: "test/mixed_test.rb", line: 5, resolve: "path" },
|
||||
});
|
||||
// An error has no location in its header: the first frame gives it.
|
||||
expect(events).toContainEqual({
|
||||
type: "location",
|
||||
path: ["MixedTest", "test_error"],
|
||||
location: { path: "test/mixed_test.rb", line: 6, resolve: "path" },
|
||||
});
|
||||
});
|
||||
|
||||
it("locates every test by class and name, once, for jumping to its source", () => {
|
||||
const located = events.filter((e) => e.type === "location" && e.location.resolve === "rubyTest");
|
||||
expect(located).toHaveLength(7);
|
||||
expect(located).toContainEqual({
|
||||
type: "location",
|
||||
path: ["MixedTest", "test_pass"],
|
||||
location: { path: "test_pass", className: "MixedTest", resolve: "rubyTest" },
|
||||
});
|
||||
// The first location wins in the tree: passing tests have no other.
|
||||
expect(results.get(["MixedTest", "test_pass"].join("\u0000"))!.location?.resolve).toBe("rubyTest");
|
||||
});
|
||||
|
||||
it("summarises the run", () => {
|
||||
expect(events.at(-1)).toEqual({ type: "summary", passed: 4, failed: 2, skipped: 1, durationMs: 0.893 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ruby test reporting", () => {
|
||||
const cfg = (kind: string, command: string, args: string[]) => ({ kind, command, args });
|
||||
|
||||
it("asks Minitest for one line per test", () => {
|
||||
expect(withTestReporting(cfg("ruby", "ruby", ["-Ilib", "test/a_test.rb"])).args).toEqual([
|
||||
"-Ilib",
|
||||
"test/a_test.rb",
|
||||
"-v",
|
||||
]);
|
||||
expect(withTestReporting(cfg("rake", "bundle", ["exec", "rake", "test"])).args).toEqual([
|
||||
"exec",
|
||||
"rake",
|
||||
"test",
|
||||
"TESTOPTS=-v",
|
||||
]);
|
||||
expect(withTestReporting(cfg("ruby", "ruby", ["a_test.rb", "-v"])).args).toEqual(["a_test.rb", "-v"]);
|
||||
expect(withTestReporting(cfg("rake", "rake", ["test", "TESTOPTS=--seed 1"])).args).toEqual([
|
||||
"test",
|
||||
"TESTOPTS=--seed 1 -v",
|
||||
]);
|
||||
expect(withTestReporting(cfg("rake", "rake", ["test", "TESTOPTS=--name=Foo#test_a"])).args).toEqual([
|
||||
"test",
|
||||
"TESTOPTS=--name=Foo#test_a -v",
|
||||
]);
|
||||
// RSpec has no parser yet: untouched.
|
||||
expect(withTestReporting(cfg("rspec", "rspec", ["spec"])).args).toEqual(["spec"]);
|
||||
});
|
||||
|
||||
it("is the framework of Ruby configurations", () => {
|
||||
expect(frameworkFor(cfg("ruby", "ruby", []))).toBe("ruby");
|
||||
expect(frameworkFor(cfg("rake", "bundle", ["exec", "rake", "test"]))).toBe("ruby");
|
||||
});
|
||||
});
|
||||
|
||||
// Captured from a run with the IDE's reporter (Minitest 6.0.6): records end with their own newline, so the
|
||||
// progress dots stay on one line once they are taken out.
|
||||
const M = "@@OPENIDEA@@";
|
||||
const F = "/p/test/mixed_test.rb";
|
||||
const REPORTER_STREAM = [
|
||||
"# Running:",
|
||||
"",
|
||||
`${M}{"t":"start","c":"MixedTest","n":"test_pass","f":"${F}","l":4}`,
|
||||
`.${M}{"t":"result","c":"MixedTest","n":"test_pass","s":"passed","d":0.000063,"f":"${F}","l":4,"m":""}`,
|
||||
`${M}{"t":"start","c":"MixedTest","n":"test_fail","f":"${F}","l":5}`,
|
||||
`F${M}{"t":"result","c":"MixedTest","n":"test_fail","s":"failed","d":0.000054,"f":"${F}","l":5,"m":"Expected: 4\\n Actual: 3"}`,
|
||||
`${M}{"t":"start","c":"MixedTest","n":"test_prints","f":"${F}","l":8}`,
|
||||
"hello from test",
|
||||
`.${M}{"t":"result","c":"MixedTest","n":"test_prints","s":"passed","d":0.000007,"f":"${F}","l":8,"m":""}`,
|
||||
`${M}{"t":"start","c":"MixedTest","n":"test_skip","f":"${F}","l":7}`,
|
||||
`S${M}{"t":"result","c":"MixedTest","n":"test_skip","s":"skipped","d":0.000006,"f":"${F}","l":7,"m":"later"}`,
|
||||
"",
|
||||
`${M}{"t":"summary","runs":4,"failures":1,"errors":0,"skips":1,"d":0.0011}`,
|
||||
"Finished in 0.001124s",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
describe("the IDE's Minitest reporter", () => {
|
||||
const collect = (chunks: string[]) => {
|
||||
const lines: string[] = [];
|
||||
const out = new RubyTestOutput((line) => lines.push(line));
|
||||
const text = chunks.map((c) => out.feed(c)).join("") + out.end();
|
||||
return { text, records: lines.filter((l) => l.includes(M)), lines };
|
||||
};
|
||||
|
||||
it("takes the records out of the console, exactly as Minitest prints them without it", () => {
|
||||
const { text, records } = collect([REPORTER_STREAM]);
|
||||
expect(text).toBe("# Running:\n\n.Fhello from test\n.S\nFinished in 0.001124s\n");
|
||||
expect(records).toHaveLength(9);
|
||||
expect(records.every((r) => r.includes(M))).toBe(true);
|
||||
});
|
||||
|
||||
it("is the same however the output is cut into chunks", () => {
|
||||
const whole = collect([REPORTER_STREAM]);
|
||||
const chars = collect(REPORTER_STREAM.split(""));
|
||||
const odd = collect(REPORTER_STREAM.match(/[\s\S]{1,7}/g)!);
|
||||
expect(chars).toEqual(whole);
|
||||
expect(odd).toEqual(whole);
|
||||
});
|
||||
|
||||
it("drops a record cut off by the end of the process, not the text before it", () => {
|
||||
expect(collect([`abc${M}{"t":"sta`]).text).toBe("abc");
|
||||
expect(collect(["no marker @@OPEN"]).text).toBe("no marker @@OPEN\n");
|
||||
});
|
||||
|
||||
describe("results", () => {
|
||||
const parser = rubyParser();
|
||||
const results = new TestResults();
|
||||
for (const l of collect([REPORTER_STREAM]).lines) results.apply(parser.feed(l));
|
||||
// The console's own text arrives too: the same results, printed.
|
||||
for (const l of ["MixedTest#test_pass = 0.00 s = .", "1 runs, 1 assertions, 0 failures, 0 errors, 0 skips"])
|
||||
results.apply(parser.feed(l));
|
||||
const node = (name: string) => results.get(["MixedTest", name].join("\u0000"))!;
|
||||
|
||||
it("builds the tree with statuses, times and failure text", () => {
|
||||
expect(nodeStatus(node("test_pass"))).toBe("passed");
|
||||
expect(nodeStatus(node("test_fail"))).toBe("failed");
|
||||
expect(nodeStatus(node("test_skip"))).toBe("skipped");
|
||||
expect(node("test_fail").output).toEqual(["Expected: 4", " Actual: 3"]);
|
||||
expect(node("test_pass").durationMs).toBeCloseTo(0.063, 3);
|
||||
});
|
||||
|
||||
it("gives every test its real file and line, passed or not", () => {
|
||||
for (const [name, line] of [
|
||||
["test_pass", 4],
|
||||
["test_fail", 5],
|
||||
["test_prints", 8],
|
||||
["test_skip", 7],
|
||||
] as const)
|
||||
expect(node(name).location).toEqual({ path: F, line, resolve: "path" });
|
||||
});
|
||||
|
||||
it("summarises from the reporter, and ignores the repeated text", () => {
|
||||
expect(results.summary).toMatchObject({ passed: 2, failed: 1, skipped: 1 });
|
||||
expect(results.get("MixedTest")!.children).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
it("a real location replaces the place to search for", () => {
|
||||
const results = new TestResults();
|
||||
results.apply(parseAll(rubyParser(), "MixedTest#test_pass = 0.00 s = ."));
|
||||
const id = ["MixedTest", "test_pass"].join("\u0000");
|
||||
expect(results.get(id)!.location?.resolve).toBe("rubyTest");
|
||||
results.apply([
|
||||
{ type: "location", path: ["MixedTest", "test_pass"], location: { path: F, line: 4, resolve: "path" } },
|
||||
]);
|
||||
expect(results.get(id)!.location).toEqual({ path: F, line: 4, resolve: "path" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Minitest's verbose output (`-v`; Test::Unit's minitest shim prints the same): one
|
||||
* `Class#test_name = 0.00 s = .` line per test (`.` passed, `F` failed, `E` error, `S` skipped),
|
||||
* then the failure sections and the summary. Tests are reported as `[Class, test_name]`.
|
||||
*
|
||||
* ```text
|
||||
* MixedTest#test_prints = hello from test <- the test's own output comes first,
|
||||
* 0.00 s = . the result follows on a line of its own
|
||||
*
|
||||
* 1) Failure:
|
||||
* MixedTest#test_fail [test/mixed_test.rb:5]:
|
||||
* Expected: 4
|
||||
* Actual: 3
|
||||
*
|
||||
* 7 runs, 5 assertions, 1 failures, 1 errors, 1 skips
|
||||
* ```
|
||||
*/
|
||||
import { parseDuration, TestStdout, type TestEvent, type TestOutputParser, type TestStatus } from "./types";
|
||||
|
||||
// A class may be a spec description with spaces (`Spec style#test_0001_works`).
|
||||
const RESULT_RE = /^(.+?)#(\S+) = ([\d.]+ s) = ([.FES])$/;
|
||||
const STARTED_RE = /^(.+?)#(\S+) = (.*)$/;
|
||||
/** The result of a test that printed output first. */
|
||||
const LATE_RESULT_RE = /^([\d.]+ s) = ([.FES])$/;
|
||||
const SECTION_RE = /^\s*\d+\) (Failure|Error|Skipped):$/;
|
||||
const SECTION_TEST_RE = /^(.+?)#(\S+?)(?: \[(.+?):(\d+)\])?:$/;
|
||||
const FRAME_RE = /^\s+(\S+?):(\d+):in /;
|
||||
const SUMMARY_RE = /^(\d+) runs, \d+ assertions, (\d+) failures, (\d+) errors, (\d+) skips/;
|
||||
const FINISHED_RE = /^Finished in ([\d.]+s)/;
|
||||
/** Gems and the standard library never locate a failure. */
|
||||
const LIBRARY_RE = /[/\\](?:gems|rubygems|lib[/\\]ruby)[/\\]/;
|
||||
|
||||
const STATUS: Record<string, Exclude<TestStatus, "running">> = {
|
||||
".": "passed",
|
||||
F: "failed",
|
||||
E: "failed",
|
||||
S: "skipped",
|
||||
};
|
||||
|
||||
/** Minitest names no file for a passing test: its location is searched for when the user jumps to it. */
|
||||
const located = (path: string[]): TestEvent => ({
|
||||
type: "location",
|
||||
path,
|
||||
location: { path: path[1]!, className: path[0], resolve: "rubyTest" },
|
||||
});
|
||||
|
||||
/** Starts a record of the IDE's own Minitest reporter (`ide-run/src/ruby_reporter`); a JSON object follows. */
|
||||
export const RECORD_MARK = "@@OPENIDEA@@";
|
||||
|
||||
interface ReporterRecord {
|
||||
t: "start" | "result" | "summary";
|
||||
c?: string;
|
||||
n?: string;
|
||||
s?: "passed" | "failed" | "skipped";
|
||||
/** Seconds. */
|
||||
d?: number | null;
|
||||
f?: string | null;
|
||||
l?: number | null;
|
||||
m?: string;
|
||||
runs?: number;
|
||||
failures?: number;
|
||||
errors?: number;
|
||||
skips?: number;
|
||||
}
|
||||
|
||||
/** What a reporter record says: the test's real file and line, its status, time and failure text. */
|
||||
function fromRecord(json: string): TestEvent[] {
|
||||
let r: ReporterRecord;
|
||||
try {
|
||||
r = JSON.parse(json) as ReporterRecord;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (r.t === "summary") {
|
||||
const failed = (r.failures ?? 0) + (r.errors ?? 0);
|
||||
const skipped = r.skips ?? 0;
|
||||
return [
|
||||
{
|
||||
type: "summary",
|
||||
passed: Math.max(0, (r.runs ?? 0) - failed - skipped),
|
||||
failed,
|
||||
skipped,
|
||||
durationMs: typeof r.d === "number" ? Math.round(r.d * 1000 * 1000) / 1000 : undefined,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (!r.c || !r.n) return [];
|
||||
const path = [r.c, r.n];
|
||||
const events: TestEvent[] = [];
|
||||
if (r.t === "start") events.push({ type: "start", path });
|
||||
if (r.t === "result" && r.s) {
|
||||
events.push({
|
||||
type: "result",
|
||||
path,
|
||||
status: r.s,
|
||||
durationMs: typeof r.d === "number" ? Math.round(r.d * 1000 * 1000) / 1000 : undefined,
|
||||
});
|
||||
for (const line of (r.m ?? "").split("\n")) if (line) events.push({ type: "output", path, line });
|
||||
}
|
||||
if (r.f && r.l) {
|
||||
events.push({ type: "location", path, location: { path: r.f, line: r.l, resolve: "path" } });
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* The console side of the reporter: a record ends with its own newline, so what the line holds before the
|
||||
* marker (a progress dot) is all the console shows of it.
|
||||
*/
|
||||
export class RubyTestOutput extends TestStdout {
|
||||
constructor(feedLine: (line: string) => void) {
|
||||
super(feedLine, (line) => {
|
||||
const at = line.indexOf(RECORD_MARK);
|
||||
return at < 0 ? line + "\n" : line.slice(0, at);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function rubyParser(): TestOutputParser {
|
||||
/** The reporter's records are the source of results; the text is then ignored (it would repeat them). */
|
||||
let records = false;
|
||||
const seen = new Set<string>();
|
||||
/** The test's own location, once. */
|
||||
const locate = (path: string[]): TestEvent[] => {
|
||||
const id = path.join("\0");
|
||||
if (seen.has(id)) return [];
|
||||
seen.add(id);
|
||||
return [located(path)];
|
||||
};
|
||||
/** A test whose result line has not been printed yet: its own output came first. */
|
||||
let pending: string[] | null = null;
|
||||
let section: { path: string[] | null; located: boolean; expectTest: boolean } | null = null;
|
||||
let finished: number | undefined;
|
||||
|
||||
return {
|
||||
feed(line) {
|
||||
const mark = line.indexOf(RECORD_MARK);
|
||||
if (mark >= 0) {
|
||||
records = true;
|
||||
return fromRecord(line.slice(mark + RECORD_MARK.length));
|
||||
}
|
||||
if (records) return [];
|
||||
const events: TestEvent[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
|
||||
if ((m = SUMMARY_RE.exec(line))) {
|
||||
section = null;
|
||||
const [runs, failures, errors, skips] = [m[1], m[2], m[3], m[4]].map(Number) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
events.push({
|
||||
type: "summary",
|
||||
passed: Math.max(0, runs - failures - errors - skips),
|
||||
failed: failures + errors,
|
||||
skipped: skips,
|
||||
durationMs: finished,
|
||||
});
|
||||
return events;
|
||||
}
|
||||
if ((m = FINISHED_RE.exec(line))) {
|
||||
finished = parseDuration(m[1]);
|
||||
pending = null;
|
||||
return events;
|
||||
}
|
||||
if (SECTION_RE.test(line)) {
|
||||
section = { path: null, located: false, expectTest: true };
|
||||
return events;
|
||||
}
|
||||
if (section) {
|
||||
if (section.expectTest) {
|
||||
const t = SECTION_TEST_RE.exec(line);
|
||||
section.expectTest = false;
|
||||
if (t) {
|
||||
section.path = [t[1]!, t[2]!];
|
||||
events.push({ type: "output", path: section.path, line });
|
||||
if (t[3]) {
|
||||
section.located = true;
|
||||
events.push({
|
||||
type: "location",
|
||||
path: section.path,
|
||||
location: { path: t[3], line: Number(t[4]), resolve: "path" },
|
||||
});
|
||||
}
|
||||
return events;
|
||||
}
|
||||
}
|
||||
if (section.path) {
|
||||
events.push({ type: "output", path: section.path, line });
|
||||
const frame = FRAME_RE.exec(line);
|
||||
// An error has no `[file:line]` in its header: its first frame outside the libraries is the place.
|
||||
if (frame && !section.located && !LIBRARY_RE.test(frame[1]!)) {
|
||||
section.located = true;
|
||||
events.push({
|
||||
type: "location",
|
||||
path: section.path,
|
||||
location: { path: frame[1]!, line: Number(frame[2]), resolve: "path" },
|
||||
});
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
if ((m = RESULT_RE.exec(line))) {
|
||||
const path = [m[1]!, m[2]!];
|
||||
events.push({ type: "result", path, status: STATUS[m[4]!]!, durationMs: parseDuration(m[3]) }, ...locate(path));
|
||||
pending = null;
|
||||
return events;
|
||||
}
|
||||
if (pending && (m = LATE_RESULT_RE.exec(line))) {
|
||||
events.push({ type: "result", path: pending, status: STATUS[m[2]!]!, durationMs: parseDuration(m[1]) });
|
||||
pending = null;
|
||||
return events;
|
||||
}
|
||||
if ((m = STARTED_RE.exec(line)) && /^(test|\w*_\d+)/.test(m[2]!)) {
|
||||
pending = [m[1]!, m[2]!];
|
||||
events.push({ type: "start", path: pending }, ...locate(pending));
|
||||
if (m[3]) events.push({ type: "output", path: pending, line: m[3] });
|
||||
return events;
|
||||
}
|
||||
if (pending) events.push({ type: "output", path: pending, line });
|
||||
return events;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -10,8 +10,8 @@ export interface TestLocation {
|
||||
/** 1-based, as printed. */
|
||||
line?: number;
|
||||
col?: number;
|
||||
/** `name`: a file name to look up in the project (JVM / Go frames). */
|
||||
resolve: "path" | "name";
|
||||
/** `name`: a file name to look up in the project (JVM / Go frames); `rubyTest`: a test to search for (`rubyTests.ts`). */
|
||||
resolve: "path" | "name" | "rubyTest";
|
||||
/** JVM frames: the class's binary name; `path` is then the file under its package directory. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { call, commands, nextRequestId, type LanguageInfo } from "@/platform/ipc
|
||||
import { inFileSet } from "@/platform/projectRoots";
|
||||
import { searchEndpoints, searchableSettings } from "@/workbench/contributions";
|
||||
import { projectRoot } from "../common/open";
|
||||
import { cancelRequest, streamSearch } from "../common/textSearch";
|
||||
import { cancelRequest, streamSearch } from "@/platform/textSearch";
|
||||
import type { SeDeps } from "./controller";
|
||||
import { recentActions } from "./store";
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { FindResultsSource } from "@/workbench/contributions";
|
||||
import type { MenuItem } from "@/ui";
|
||||
import { loadJson, saveJson } from "../common/open";
|
||||
import { maybeToolWindowService } from "../common/services";
|
||||
import { cancelRequest, streamSearch } from "../common/textSearch";
|
||||
import { cancelRequest, streamSearch } from "@/platform/textSearch";
|
||||
import { FindSession, IDLE, countFileMatches, type FindDeps, type FindState } from "./session";
|
||||
|
||||
export interface FindTab {
|
||||
|
||||
@@ -62,6 +62,42 @@ describe("plugin inspections", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PHPStan inspections", () => {
|
||||
it("list each identifier below PHPStan, from seen codes and stored overrides", () => {
|
||||
const tree = buildInspectionTree(
|
||||
[{ name: "PHP", servers: ["intelephense"] }],
|
||||
[
|
||||
{
|
||||
producer: "phpstan",
|
||||
code: "return.type",
|
||||
source: "PHPStan",
|
||||
sample: "Should return int but returns string.",
|
||||
},
|
||||
{ producer: "build", code: "E0308", source: "rustc", sample: "" },
|
||||
],
|
||||
[{ "builtin:phpstan:constant.notFound": { enabled: false }, "builtin:build:E1": { enabled: false } }],
|
||||
);
|
||||
const php = find(tree, "group:PHP")!;
|
||||
expect(php.children.map((n) => n.id)).toEqual(["lsp:intelephense", "builtin:phpstan"]);
|
||||
const phpstan = find(tree, "PHP/builtin:phpstan")!;
|
||||
expect(phpstan.children.map((n) => [n.id, n.name])).toEqual([
|
||||
["builtin:phpstan:constant.notFound", "constant.notFound"],
|
||||
["builtin:phpstan:return.type", "return.type"],
|
||||
]);
|
||||
expect(phpstan.children[1]!.description).toBe(
|
||||
"PHPStan problems with code return.type.\n\nExample: Should return int but returns string.",
|
||||
);
|
||||
expect(pathToInspection(tree, "builtin:phpstan:return.type")).toEqual([
|
||||
"group:PHP",
|
||||
"PHP/builtin:phpstan",
|
||||
"PHP/builtin:phpstan:return.type",
|
||||
]);
|
||||
// Other built-in checks stay single inspections.
|
||||
expect(find(tree, "General/builtin:build")!.children).toEqual([]);
|
||||
expect(checkState({ "builtin:phpstan:constant.notFound": { enabled: false } }, phpstan)).toBe("mixed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("inspections tree", () => {
|
||||
const tree = buildInspectionTree(languages, seen, [{ "lsp:ruff:E501": { severity: "typo" } }]);
|
||||
|
||||
@@ -72,6 +108,7 @@ describe("inspections tree", () => {
|
||||
"Java",
|
||||
"JavaScript",
|
||||
"Other",
|
||||
"PHP",
|
||||
"Proofreading",
|
||||
"Python",
|
||||
"SQL",
|
||||
|
||||
@@ -63,7 +63,12 @@ describe("languages settings", () => {
|
||||
languageLevel: "",
|
||||
includePaths: [],
|
||||
stubs: [],
|
||||
phpstanConfig: "",
|
||||
});
|
||||
expect(validateLanguages({ php: { phpstanConfig: " build/phpstan.neon " } }).php.phpstanConfig).toBe(
|
||||
"build/phpstan.neon",
|
||||
);
|
||||
expect(validateLanguages({ php: { phpstanConfig: 3 } }).php.phpstanConfig).toBe("");
|
||||
const lists = validateLanguages({ php: { includePaths: [" ../platform ", "", 3, "../platform"], stubs: "redis" } });
|
||||
expect(lists.php.includePaths).toEqual(["../platform"]);
|
||||
expect(lists.php.stubs).toEqual([]);
|
||||
|
||||
@@ -162,6 +162,16 @@ export function PhpPage() {
|
||||
/>
|
||||
</Row>
|
||||
</Section>
|
||||
<Section title="PHPStan">
|
||||
<PathInput
|
||||
k="languages.php.phpstanConfig"
|
||||
label="Configuration file"
|
||||
placeholder="phpstan.neon, phpstan.neon.dist or phpstan.dist.neon"
|
||||
hint="The configuration vendor/bin/phpstan checks saved files with. Empty uses phpstan.neon, phpstan.neon.dist or phpstan.dist.neon in the project root. Absolute, or relative to the project."
|
||||
title="Select PHPStan Configuration File"
|
||||
file
|
||||
/>
|
||||
</Section>
|
||||
<Section title="CLI Interpreter">
|
||||
<SdkRow
|
||||
k="languages.php.interpreter"
|
||||
|
||||
@@ -60,18 +60,22 @@ function serverNode(
|
||||
: languages
|
||||
? tx`Problems the ${server} language server reports in ${languages} files. Severity "As reported" keeps the level the server assigns; a code below can override it.`
|
||||
: tx`Problems the ${server} language server reports. Severity "As reported" keeps the level the server assigns; a code below can override it.`,
|
||||
children: [...codes]
|
||||
.sort((a, b) => a.code.localeCompare(b.code, undefined, { numeric: true }))
|
||||
.map((c) => ({
|
||||
key: `${group}/${codeInspectionId(id, c.code)}`,
|
||||
id: codeInspectionId(id, c.code),
|
||||
name: c.code,
|
||||
description: codeDescription(plugin ?? server, c),
|
||||
children: [],
|
||||
})),
|
||||
children: codeNodes(group, id, plugin ?? server, codes),
|
||||
};
|
||||
}
|
||||
|
||||
function codeNodes(group: string, id: string, owner: string, codes: readonly SeenCode[]): InspectionNode[] {
|
||||
return [...codes]
|
||||
.sort((a, b) => a.code.localeCompare(b.code, undefined, { numeric: true }))
|
||||
.map((c) => ({
|
||||
key: `${group}/${codeInspectionId(id, c.code)}`,
|
||||
id: codeInspectionId(id, c.code),
|
||||
name: c.code,
|
||||
description: codeDescription(owner, c),
|
||||
children: [],
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The inspection tree: one group per language that has language servers, the
|
||||
* built-in checks under their category, and servers known only from seen
|
||||
@@ -98,6 +102,9 @@ export function buildInspectionTree(
|
||||
const m = /^lsp:(plugin:[^:]+|[^:]+)(?::(.+))?$/.exec(id);
|
||||
if (m?.[2]) addCode({ producer: m[1]!, code: m[2], source: "", sample: "" });
|
||||
else if (m && !codes.has(m[1]!)) codes.set(m[1]!, new Map());
|
||||
const builtin = BUILTIN_INSPECTIONS.find((b) => b.byCode && id.startsWith(`${b.id}:`));
|
||||
if (builtin)
|
||||
addCode({ producer: builtin.producer, code: id.slice(builtin.id.length + 1), source: "", sample: "" });
|
||||
}
|
||||
}
|
||||
const servedBy = new Map<string, string[]>();
|
||||
@@ -125,7 +132,7 @@ export function buildInspectionTree(
|
||||
id: b.id,
|
||||
name: b.name,
|
||||
description: b.description,
|
||||
children: [],
|
||||
children: b.byCode ? codeNodes(b.group, b.id, b.name, [...(codes.get(b.producer)?.values() ?? [])]) : [],
|
||||
});
|
||||
}
|
||||
for (const [producer, list] of codes) {
|
||||
|
||||
@@ -153,6 +153,8 @@ export const LANGUAGE_PAGES: PageSpec[] = [
|
||||
"php",
|
||||
"composer",
|
||||
"phpunit",
|
||||
"phpstan",
|
||||
"phpstan.neon",
|
||||
"xampp",
|
||||
"herd",
|
||||
"homebrew",
|
||||
|
||||
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user