Compare commits

..
Author SHA1 Message Date
l0ng-ai 58afe0b154 fix(editor): colour PHP variables and function calls
PHP editors showed $variables and function calls in the plain text
colour, because the Language Defaults that PHP inherits leave both
uncoloured. The built-in schemes now give PHP its own colours for them
(PHP_VAR, PHP_FUNCTION_CALL): each scheme's instance-field purple and
function blue. An imported scheme's PHP_VAR / PHP_FUNCTION_CALL and the
user's PHP overrides still take precedence.
2026-11-05 23:07:58 +08:00
l0ng-aiandClaude Opus 5.5 b70c01d1d6 fix(tomcat): keep the web app's META-INF/context.xml in the generated descriptor (#1576)
Tomcat ignores an app's META-INF/context.xml once conf/Catalina/localhost has a descriptor for it, and the run configuration always wrote a bare <Context docBase reloadable/>, dropping the app's Resource/Environment/Valve/Realm entries. The descriptor now starts from the app's own context.xml (exploded directory or WAR, all three deployment kinds) with docBase and reloadable set by the IDE and path dropped; unreadable or non-<Context> files fall back to the bare descriptor.

Fixes #1514

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 22:53:08 +08:00
Jacob Kjeldahlandkjeldahl 653ebd578a fix(ruby): Ruby LSP no longer crash-loops on dead shims or a spaced gem home (#1534)
Ruby LSP showed Crashed and restarted for two reasons. A version-manager shim for ruby-lsp exists for every Ruby that ever had the gem, so when the active Ruby lacked it the shim exited on every start while the IDE counted the server as installed. And the IDE-managed copy lived in a gem home under Application Support, whose space broke ruby-lsp's re-exec through Bundler.

Shims that can't run the tool now count as missing. ruby-lsp is installed with the project's own Ruby (gem install in the project, so a version manager picks its Ruby). Its executable is looked up in that Ruby's gem bindir and user bin when they are off PATH (Homebrew), and those directories are added to the server's PATH.

Co-authored-by: kjeldahl <[email protected]>
2026-11-05 22:38:10 +08:00
l0ng-ai 6e6baf4284 fix(vcs): translate the question in the add/remove to Git confirmation (#1575)
The confirmation body joined the English question and the file list into one string, which never matched a catalog entry. The question is now translated on its own before the list is appended.
2026-11-05 22:32:09 +08:00
l0ng-ai de09ee0678 fix(windows): reopen every project window on startup (#1574)
"Reopen projects on startup" only saved window groups, so windows opened with New Window were lost. The saved session now lists every open project window (groups with their tabs and selection, single windows on their own), and the previous run's session is read before this run first overwrites it, so the startup project can't replace it before the other windows are restored.
2026-11-05 22:29:41 +08:00
l0ng-ai 292dc3cade fix(editor): log why an edit was rejected before the editor drops it (#1569)
When the backend rejects an edit for any reason other than a stale version, the editor replaces its unsaved text with the file's content, but the reason was only printed to the webview console. The backend now logs the rejected edit with its path, and the editor writes the sync error and the discarded edits to the log file, so reports of edits that roll back (#1530, #1522) can be diagnosed from the log.
2026-11-05 22:04:49 +08:00
Muuandmuutot d4a5b54720 feat(editor): add PyCharm-style Python semantic highlighting (#1523)
Python files got semantic colours only once the language server answered, and locals, globals, instance and class attributes, constants, properties and type parameters all looked the same. A highlighter on the Lezer syntax tree now colours them right away in PyCharm's finer categories (also self, builtins, decorators, definitions, method calls, brackets); LSP semantic tokens stay authoritative when they arrive. The categories are new --syn-python-* theme tokens wired through the built-in themes and Harbor Night, and PyCharm .icls schemes import their PY.* attributes.

Co-authored-by: muutot <[email protected]>
2026-11-05 21:53:22 +08:00
Daniel AdámekandDanielADK c495c09416 feat(php): show PHPStan errors in the editor on save (#1518)
PHPStan results were only visible in a terminal. Saving a PHP file in a trusted project with vendor/bin/phpstan now runs phpstan analyse --error-format=json on the saved files (debounced, from the project root, on the selected PHP with XDEBUG_MODE=off) and shows its errors at their lines in the editor and Problems, with the PHPStan identifier as the code; saving the fixed code clears them. Settings > Editor > Inspections lists PHPStan under PHP with each reported identifier, so rules can be turned off or re-graded, and Settings > Languages & Frameworks > PHP > PHPStan selects another configuration file. A failing run is reported once as a notification; a newer save cancels a check in progress, and Restricted Mode stops it.

Closes #1212

Co-authored-by: DanielADK <[email protected]>
2026-11-05 21:50:06 +08:00
Jaw 324dded5c1 feat(vcs): mark diff and merge changes on the scrollbar (#1519)
The diff and merge viewers had no overview lane, so changes outside the viewport were invisible. The scrollbar now marks every change, coloured by type (modified, inserted, deleted, conflict), in the side-by-side view (both panes), the aligned view (one stripe over the shared scroller), the unified view and the three-way merge editor; clicking a mark jumps to the change. Marks span the lane's width from the first to the last changed line with a 2px minimum. The colours are new --diff-*-stripe theme tokens (Darcula, Light and High-contrast values), mapped from ERROR_STRIPE_COLOR on .icls import and set in the Harbor Night theme template.

Part of #1505

Co-authored-by: jaw52 <[email protected]>
2026-11-05 21:43:55 +08:00
Hacbit 36fd117b29 feat(vcs): stage Unity metadata with new assets (#1509)
In a Unity project, Add File to Git for a new asset under Assets/ now takes its .meta along: an untracked .meta that already exists is listed in the same confirmation, and one Unity writes later is added once the asset's addition has succeeded. The ask / silently / never setting applies as before; pending metadata is dropped when the asset is unstaged or deleted, or the project or repository changes. File-operation paths are compared in IPC form, so they match the status paths on Windows.

Closes #1429

Co-authored-by: hacbit <[email protected]>
2026-11-05 21:42:08 +08:00
Jacob Kjeldahlandkjeldahl 9fb38769ad feat(run): load a Minitest reporter into Ruby test runs (#1547)
Parsing Minitest's console output needs -v, gives no file or line for passing tests and depends on how output interleaves. Ruby test runs (ruby and rake test configurations in the console) now load a small Minitest reporter through RUBYLIB/RUBYOPT that prints one record per test on stdout with its status, time, failure text and real file and line. The console strips the records and the test tree is built from them, so jump to source gets exact locations; the text parser stays as the fallback. It works on Minitest 5 and 6 (6 no longer loads plugins on its own, so a small hook registers the reporter once Minitest is loaded).

Co-authored-by: kjeldahl <[email protected]>
2026-11-05 21:39:36 +08:00
Jacob Kjeldahlandkjeldahl 087cb4cbaa feat(run): Ruby test tree, jump to source and gutter actions (#1546)
Ruby test runs showed only console text: there was no Ruby output parser and test_*.rb was not a test file. A Minitest parser now builds the test tree (class, test, status, time, failure text) from verbose output (-v / TESTOPTS=-v), test_*.rb counts as a test file, jump to source finds the class and then its def, and the gutter gets a run action per test method (coloured by the last result) and per test class and module that holds test classes; one test, class or module runs with --name=....

The streaming Find in Files helper the jump to source uses moved from the search feature to src/platform, so the run feature doesn't import another feature.

Co-authored-by: kjeldahl <[email protected]>
2026-11-05 21:38:12 +08:00
Jacob Kjeldahlandkjeldahl f6a1823121 feat(run): run Ruby tests from the editor and Project view (-I roots, test-root folders) (#1545)
A Ruby file run from the context menu had no project load path, so require "foo" for lib/foo.rb failed: Sources roots are now passed as -I<root>. Folders under a test root get Run Tests in '<dir>': RSpec on the folder, rake test with TEST=<globs> (two comma-separated globs, since Rake splits TEST at commas), or a detected minitest configuration that loads every *_test.rb / test_*.rb under the folder when the project has neither.

Co-authored-by: kjeldahl <[email protected]>
2026-11-05 21:37:08 +08:00
Jacob Kjeldahlandkjeldahl 4959b430b1 feat(coverage): Run with Coverage and report import for Ruby (SimpleCov) (#1535)
Ruby had no coverage runner. Run with Coverage now works for ruby, bundle, rake, rspec and rails test configurations through SimpleCov, read from its .resultset.json (lines and branches). A project that starts SimpleCov itself keeps its own setup; otherwise a small preload starts it and writes the report to the IDE's run directory, so nothing is written into the project. The preload is loaded through RUBYLIB and a bare -r in RUBYOPT, because RUBYOPT can't take the spaces in the macOS data directory, and it steps aside in the bundle exec launcher, which loads it before Bundler puts the project's gems on the load path. A CI .resultset.json can be imported through Import a Coverage Report.

Co-authored-by: kjeldahl <[email protected]>
2026-11-05 21:36:13 +08:00
l0ng-ai 915fec598f test(e2e): wait for xterm to open before pasting into the terminal
xterm now opens on a later frame, once its pane has a size, so the clipboard
test queried the helper textarea before it existed.
2026-11-05 21:30:14 +08:00
xtr-hubandxtr-hub 94576eb879 fix(vcs): keep changes left out of a commit excluded (#1556)
A successful commit cleared every inclusion override in the Commit tool window, so files the user had unchecked fell back to included and could go into the next commit by accident. commitChanges now drops the overrides (checks and per-file block exclusions) only for the changes that went into the commit; files left out stay excluded.

Closes #1539

Co-authored-by: xtr-hub <[email protected]>
2026-11-05 21:23:05 +08:00
tomgorandtomgor 8c915664e9 feat(terminal): restore each project's terminal tabs (#1526)
Restarting the app dropped every open terminal. Each project now remembers its terminal tabs: their working directories, renames and the split layout. They come back as new shells in the saved directories (scrollback is not kept; a predefined shell is started again, a one-shot command is not). Switching projects in a window saves the old project's tabs and opens the new one's.

xterm now opens only once its pane has a size: opening it while the tool window was still 0x0 (its first layout, or a tab under display:none) measured a zero cell and left the first tab unable to take keys. A hidden tab stays closed until it is shown, and focus requested before xterm opens is applied when it does.

Co-authored-by: tomgor <[email protected]>
2026-11-05 21:06:10 +08:00
DomKingandDomKing 253fcfb7e0 feat(debugger): search the debug console output (#1502)
The Debug console had no Find: Cmd-F did nothing there, in the Debug tool window and in Services' debug output alike. It now searches the whole buffer (Match Case / Words / Regex) with the n/m counter, highlights every match with the current one on its own background, scrolls it into view, and offers the Run console's context menu (Copy, Select All, Copy All, Find…, Clear All).

The find matching and find bar moved from the Run console into src/ui (consoleFind.ts, ConsoleFindBar.tsx) and are shared by both consoles; Enter on one of the bar's buttons activates it instead of jumping to the next match.

Co-authored-by: DomKing <[email protected]>
2026-11-05 21:03:28 +08:00
Shohruh Ismoilovandshohruh-genius 24cf1a52d5 perf(vcs): keep multi-root Git Log refresh bounded (#1554)
Refreshing the combined Git Log of several repositories merged every repository's prefetched candidates into the result, so 300 loaded rows could grow into thousands and the graph layout took seconds. A refresh now keeps the loaded window through its last surviving commit (plus new arrivals); older history comes in with paging as before. Commit selection no longer rebuilds the layout when linear-history collapsing is off, and current-branch highlighting uses the backend's reachability, falling back to a single traversal from all heads.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-05 20:59:28 +08:00
dervarex 7071fd9107 fix(ui): show tool window titles, toolbar widgets and commit groups in regular weight (#1551)
Tool window titles, the main toolbar widgets (project, branch, run configuration) and the Commit view's group labels were semibold, which made the chrome look heavy. They now use the regular weight; module roots in the Project view stay bold.

Co-authored-by: dervarex <[email protected]>
2026-11-05 20:58:51 +08:00
胡翀andzzxzz12345 df27de217e fix(editor): copy references to resolved methods (#1531)
Copy Reference on a call site now resolves the method through Go to Definition and copies its qualified name (crate::Inventory::add in Rust, the dotted module path in Python, and so on); positions without an unambiguous method target copy the project-relative path and line, as before. The clipboard write starts during the key press with a ClipboardItem whose text resolves later, because WebKit rejects a write started after the asynchronous lookup; without ClipboardItem it writes plain text once known. Python module paths also drop everything up to the last src/ directory, for monorepo layouts.

Co-authored-by: zzxzz12345 <[email protected]>
2026-11-05 20:57:52 +08:00
lleviathe bff5d11346 fix(editor): navigate on force click instead of opening macOS Look Up (#1515)
On macOS a force click on an identifier opened the system Look Up popover instead of navigating, and the popover appeared over any text in the UI, because the webview never handled WebKit's force-touch events. The input dispatcher now cancels webkitmouseforcewillbegin in every window, and the editor treats an unmodified webkitmouseforcedown like a Cmd-click (Go to Declaration or Usages). Look Up via three-finger tap or Ctrl-Cmd-D still works.

Co-authored-by: lleviathe <[email protected]>
2026-11-05 20:47:41 +08:00
Jacob Kjeldahlandkjeldahl 9a48426740 fix(run): swap ruby/bundle launcher when toggling Bundler (#1533)
A Ruby script configuration pre-fills Launcher with the interpreter path. Ticking Run with Bundler kept it and added `exec ruby`, so the run became `/…/bin/ruby exec ruby …` and failed with LoadError. A launcher that is another Ruby tool of the same interpreter (ruby, bundle, rake, rspec) now means its sibling in that directory; a launcher of the user's own (binstub, spring) is kept. The rake and RSpec types share the same helper.

Co-authored-by: kjeldahl <[email protected]>
2026-11-05 20:43:14 +08:00
l0ng-ai dadec04d62 fix(lsp): wait for a loading server before reporting no definition (#1444)
Right after a project opens, rust-analyzer answers navigation with nothing (or withdraws answers with ContentModified) until it has loaded the project, and the client treated both as final, so Go to Definition reported "Cannot find declaration to go to".

The client now enables experimental/serverStatus. While a server reports it is still loading (quiescent: false), an empty or withdrawn definition, declaration, type definition, implementation or hover answer is asked again each time a loading phase ends, for up to 30 s. Servers that don't send serverStatus behave as before.
2026-11-05 20:42:01 +08:00
149 changed files with 7087 additions and 332 deletions

No files matched your search

+4
View File
@@ -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)]
+15
View File
@@ -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);
+28 -3
View File
@@ -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();
+8
View File
@@ -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"))
+128
View File
@@ -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();
+74
View File
@@ -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());
}
}
+7
View File
@@ -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();
+1 -1
View File
@@ -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();
+16
View File
@@ -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) {
+5 -1
View File
@@ -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.
+850
View File
@@ -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();
}
}
+23 -1
View File
@@ -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);
}),
+3
View File
@@ -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);
}
+85 -2
View File
@@ -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();
+15
View File
@@ -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());
}
}
+2
View File
@@ -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;
+6
View File
@@ -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)?,
+61
View File
@@ -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");
}
}
+52
View File
@@ -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
+99 -1
View File
@@ -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=&amp;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=&amp;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(),
+19
View File
@@ -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")]);
+30
View File
@@ -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();
+33
View File
@@ -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
View File
@@ -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 });
+8
View File
@@ -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
+91 -2
View File
@@ -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 { .. }
+7 -1
View File
@@ -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",
+45
View File
@@ -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() {
+7 -1
View File
@@ -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())
+62
View File
@@ -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
View File
@@ -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` |
+90
View File
@@ -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",
);
}
});
+84
View File
@@ -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()]),
],
}),
});
+9
View File
@@ -0,0 +1,9 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
</head>
<body>
<script type="module" src="./pythonHighlight.fixture.ts"></script>
</body>
</html>
+51
View File
@@ -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") });
});
+5
View File
@@ -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 });
+1
View File
@@ -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)",
+2 -4
View File
@@ -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)
}
+64 -46
View File
@@ -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(())
+3 -1
View File
@@ -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)>>,
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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": {
+23 -1
View File
@@ -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(".");
}
+56 -2
View File
@@ -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();
});
});
+6 -3
View File
@@ -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,
);
+2 -1
View File
@@ -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);
});
+8
View File
@@ -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"],
+24 -6
View File
@@ -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
+29
View File
@@ -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();
},
);
});
+179
View File
@@ -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-");
});
});
+513
View File
@@ -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
View File
@@ -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,
),
),
+90
View File
@@ -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();
}
});
});
+34
View File
@@ -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(
+3
View File
@@ -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);
});
});
+8 -1
View File
@@ -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" ||
+2 -2
View File
@@ -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();
});
});
+275 -14
View File
@@ -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;
+14 -4
View File
@@ -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"]],
+3 -3
View File
@@ -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.
-29
View File
@@ -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;
+6 -3
View File
@@ -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;
+4 -2
View File
@@ -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;
}
+39
View File
@@ -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.
+118
View File
@@ -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);
});
});
+38 -3
View File
@@ -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"]);
+101
View File
@@ -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::/");
});
});
});
+67
View File
@@ -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();
});
});
+47
View File
@@ -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);
}
+18 -3
View File
@@ -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`. */
+18
View File
@@ -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({
+7 -3
View File
@@ -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,
+66 -2
View File
@@ -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"]);
+26 -2
View File
@@ -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(),
]);
}
}
+3 -1
View File
@@ -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": {
+222
View File
@@ -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" });
});
});
+227
View File
@@ -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;
},
};
}
+2 -2
View File
@@ -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;
}
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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",
+15 -4
View File
@@ -32,6 +32,8 @@ import {
moveTabToSplit,
newTab,
publishFocus,
refitSessions,
syncTerminalProject,
renameTab,
requestCloseSession,
requestCloseTab,
@@ -61,18 +63,21 @@ function tabTooltip(tab: TerminalTab, sessions: Parameters<typeof tabTitle>[1]):
return cwd ? `${full}\n${cwd}` : full;
}
function TerminalPane({ sessionKey, autofocus }: { sessionKey: string; autofocus: boolean }) {
function TerminalPane({ sessionKey, active, autofocus }: { sessionKey: string; active: boolean; autofocus: boolean }) {
const ref = useRef<HTMLDivElement>(null);
const status = useTerminals((s) => s.sessions[sessionKey]?.status);
const follow = useTerminals((s) => s.sessions[sessionKey]?.follow ?? true);
// Hidden tabs stay unopened. xterm.open() under display:none measures a 0 cell
// and the textarea never accepts input, even after the tab is shown.
useLayoutEffect(() => {
if (!active) return;
const el = ref.current;
const session = getSession(sessionKey);
if (!el || !session) return;
session.attach(el);
return () => session.detach(el);
}, [sessionKey]);
}, [sessionKey, active]);
return (
<div
@@ -231,7 +236,7 @@ function TabGroup({ group, corners }: { group: string; corners: Corners | null }
className={styles.panel}
hidden={t.id !== selected}
>
<TerminalPane sessionKey={t.session} autofocus={t.id === activeTab} />
<TerminalPane sessionKey={t.session} active={t.id === selected} autofocus={t.id === activeTab} />
</div>
))}
</div>
@@ -278,7 +283,13 @@ export function TerminalView() {
const el = rootRef.current;
if (!root || !el) return;
const ensureTab = () => {
if (el.clientHeight > 0 && useTerminals.getState().tabs.length === 0) newTab(root);
if (el.clientHeight <= 0) return;
// Restore this project's tabs once the pane is actually visible. A project
// switch while the runtime is loaded restores from the project subscription.
syncTerminalProject(root);
if (useTerminals.getState().tabs.length === 0) newTab(root);
// Sessions created while this window was hidden open once it actually has a box.
refitSessions();
};
ensureTab();
if (typeof ResizeObserver === "undefined") return;
+3 -3
View File
@@ -214,12 +214,12 @@ export function activate(): () => void {
if (s.settings?.editor !== prev.settings?.editor) terminalRuntime()?.applySettingsToAll();
}),
onIdeEvent("terminalExited", (e) => terminalRuntime()?.sessionForTerminalId(e.terminal)?.exited(e.code)),
// Sessions belong to the project; closing / switching it ends them.
// Sessions belong to the project. Remember this project's tabs, then open the next one's.
useProject.subscribe((s, prev) => {
const rt = terminalRuntime();
if (rt && s.project?.root !== prev.project?.root) {
if (rt.useTerminalFind.getState().open) rt.closeTerminalFind();
rt.closeAll();
rt.syncTerminalProject(s.project?.root ?? null);
}
}),
];
@@ -242,6 +242,6 @@ export function activate(): () => void {
return () => {
disposers.reverse().forEach((d) => d());
terminalRuntime()?.closeAll();
terminalRuntime()?.unbindTerminals();
};
}
+102
View File
@@ -0,0 +1,102 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { decode } from "./persist";
import {
closeAll,
getSession,
layoutGroups,
newTab,
renameTab,
splitTab,
syncTerminalProject,
unbindTerminals,
useTerminals,
} from "./store";
vi.mock("./session", () => ({
TerminalSession: class {
key: string;
state: { cwd: string };
command: { program: string; args: string[]; shell?: boolean } | undefined;
constructor(opts: { key: string; cwd: string; command?: { program: string; args: string[]; shell?: boolean } }) {
this.key = opts.key;
this.state = { cwd: opts.cwd };
this.command = opts.command;
}
dispose() {}
},
}));
const state = () => useTerminals.getState();
const names = () => state().tabs.map((t) => t.customName ?? t.name);
beforeEach(() => {
unbindTerminals();
localStorage.clear();
closeAll();
});
describe("terminal tab memory", () => {
it("rejects a stored value that is not this version", () => {
expect(decode(null)).toBeNull();
expect(decode({ v: 2, tabs: [], layout: "g" })).toBeNull();
expect(decode({ v: 1, tabs: [{}], layout: "g" })).toMatchObject({ tabs: [] });
expect(decode({ v: 1, tabs: "nope", layout: "g" })).toBeNull();
});
it("restores each project's tabs, working directories, renames and splits", () => {
syncTerminalProject("/repo");
const app = newTab("/repo");
renameTab(app.id, "Server");
const logs = newTab("/repo/logs");
const side = splitTab(logs.id, "horizontal")!;
expect(state().activeTab).toBe(side);
getSession(logs.session)!.state.cwd = "/repo/logs/today";
useTerminals.setState((s) => ({
sessions: { ...s.sessions, [logs.session]: { ...s.sessions[logs.session]!, cwd: "/repo/logs/today" } },
}));
syncTerminalProject("/other");
expect(state().tabs).toEqual([]);
newTab("/other");
syncTerminalProject("/repo");
expect(names()).toEqual(["Server", "Local (2)", "Local (3)"]);
const restored = state().tabs;
expect(getSession(restored[0]!.session)?.state.cwd).toBe("/repo");
expect(getSession(restored[1]!.session)?.state.cwd).toBe("/repo/logs/today");
expect(getSession(restored[2]!.session)?.state.cwd).toBe("/repo/logs");
expect(layoutGroups(state().layout)).toHaveLength(2);
expect(restored[2]!.group).not.toBe(restored[1]!.group);
expect(state().activeTab).toBe(restored[2]!.id);
syncTerminalProject("/other");
expect(names()).toEqual(["Local"]);
expect(getSession(state().tabs[0]!.session)?.state.cwd).toBe("/other");
});
it("reopens a predefined shell, and a one-shot command as a plain shell in the same directory", () => {
syncTerminalProject("/repo");
newTab("/repo", "zsh", { program: "/bin/zsh", args: [], shell: true });
newTab("/repo/app", "build", { program: "npm", args: ["test"] });
syncTerminalProject(null);
syncTerminalProject("/repo");
const [shell, command] = state().tabs;
const started = (key: string) =>
getSession(key) as { command?: { program: string; args: string[]; shell?: boolean } } | undefined;
expect(started(shell!.session)?.command).toEqual({ program: "/bin/zsh", args: [], shell: true });
expect(started(command!.session)?.command).toBeUndefined();
expect(getSession(command!.session)?.state.cwd).toBe("/repo/app");
expect(command!.name).toBe("build");
});
it("keeps the saved tabs when the terminal runtime is dropped", () => {
syncTerminalProject("/repo");
const tab = newTab("/repo");
renameTab(tab.id, "Ship");
unbindTerminals();
expect(state().tabs).toEqual([]);
syncTerminalProject("/repo");
expect(names()).toEqual(["Ship"]);
expect(getSession(state().tabs[0]!.session)?.state.cwd).toBe("/repo");
});
});
+107
View File
@@ -0,0 +1,107 @@
/**
* Terminal tabs remembered per project: which tabs were open, each one's
* working directory and rename, and how the tab groups were split. Shell
* scrollback is not kept; a tab comes back as a new session in that directory.
*/
import type { GroupLayout, SplitOrientation } from "./store";
const STORAGE_PREFIX = "openidea.terminal.tabs.v1:";
interface StoredTab {
id: string;
name: string;
customName: string | null;
cwd: string;
group: string;
/** Predefined shell program. A one-shot command is not replayed. */
shell: string | null;
}
export interface StoredTerminals {
v: 1;
tabs: StoredTab[];
layout: GroupLayout;
selected: Record<string, string>;
activeTab: string | null;
}
const MAX_TABS = 32;
export function storageKey(root: string): string {
return STORAGE_PREFIX + root;
}
export function readStored(root: string): StoredTerminals | null {
try {
return decode(JSON.parse(localStorage.getItem(storageKey(root)) ?? "null"));
} catch {
return null;
}
}
export function writeStored(root: string, stored: StoredTerminals) {
try {
localStorage.setItem(storageKey(root), JSON.stringify(stored));
} catch {
/* storage unavailable or full: the tabs just aren't remembered */
}
}
export function decode(raw: unknown): StoredTerminals | null {
if (!raw || typeof raw !== "object") return null;
const v = raw as Record<string, unknown>;
if (v.v !== 1 || !Array.isArray(v.tabs)) return null;
const layout = decodeLayout(v.layout);
if (!layout) return null;
const groups = new Set(groupIds(layout));
const fallback = groupIds(layout)[0];
if (!fallback) return null;
const tabs: StoredTab[] = [];
const ids = new Set<string>();
for (const item of v.tabs) {
if (tabs.length >= MAX_TABS) break;
const tab = decodeTab(item, groups, fallback);
if (!tab || ids.has(tab.id)) continue;
ids.add(tab.id);
tabs.push(tab);
}
const selected: Record<string, string> = {};
if (v.selected && typeof v.selected === "object") {
for (const [group, id] of Object.entries(v.selected as Record<string, unknown>)) {
if (groups.has(group) && typeof id === "string" && ids.has(id)) selected[group] = id;
}
}
const activeTab = typeof v.activeTab === "string" && ids.has(v.activeTab) ? v.activeTab : null;
return { v: 1, tabs, layout, selected, activeTab };
}
function decodeTab(item: unknown, groups: Set<string>, fallback: string): StoredTab | null {
if (!item || typeof item !== "object") return null;
const t = item as Record<string, unknown>;
if (typeof t.id !== "string" || !t.id || typeof t.name !== "string" || !t.name.trim()) return null;
if (typeof t.cwd !== "string" || !t.cwd) return null;
const group = typeof t.group === "string" && groups.has(t.group) ? t.group : fallback;
const customName = typeof t.customName === "string" && t.customName.trim() ? t.customName.trim() : null;
const shell = typeof t.shell === "string" && t.shell ? t.shell : null;
return { id: t.id, name: t.name.trim(), customName, cwd: t.cwd, group, shell };
}
function decodeLayout(raw: unknown): GroupLayout | null {
if (typeof raw === "string") return raw || null;
if (!raw || typeof raw !== "object") return null;
const node = raw as Record<string, unknown>;
if (typeof node.id !== "string" || !node.id) return null;
if (node.orientation !== "horizontal" && node.orientation !== "vertical") return null;
if (!Array.isArray(node.children) || node.children.length < 2) return null;
const children: GroupLayout[] = [];
for (const child of node.children) {
const decoded = decodeLayout(child);
if (!decoded) return null;
children.push(decoded);
}
return { id: node.id, orientation: node.orientation as SplitOrientation, children };
}
function groupIds(node: GroupLayout): string[] {
return typeof node === "string" ? [node] : node.children.flatMap(groupIds);
}
+2
View File
@@ -5,6 +5,8 @@ export {
applyThemeToAll,
closeAll,
newTab,
syncTerminalProject,
unbindTerminals,
newTabAt,
requestCloseSession,
sessionForTerminalId,
+36 -2
View File
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { FIT_INTERVAL_MS } from "./fit";
import { findTerminalLinks, type TerminalLink } from "./links";
import { openFileLink } from "./session";
import { openFileLink, TerminalSession } from "./session";
const ipc = vi.hoisted(() => ({
call: vi.fn(async (v: unknown) => v),
@@ -72,3 +73,36 @@ describe("openFileLink", () => {
expect(workbench.notify).toHaveBeenCalledWith({ level: "warning", title: "File not found", body: "gone.php" });
});
});
describe("terminal open", () => {
afterEach(() => vi.useRealTimers());
it("waits to open xterm until the pane has a size", () => {
vi.useFakeTimers();
const container = document.createElement("div");
document.body.appendChild(container);
const session = new TerminalSession({
key: "s-open",
cwd: "/tmp",
onChange: () => undefined,
onFocus: () => undefined,
});
let width = 0;
let height = 0;
Object.defineProperty(session.host, "offsetWidth", { configurable: true, get: () => width });
Object.defineProperty(session.host, "offsetHeight", { configurable: true, get: () => height });
// jsdom has no canvas, so xterm's real open() cannot build a renderer.
const open = vi.spyOn(session.term, "open").mockImplementation(() => undefined);
session.attach(container);
session.focus();
vi.advanceTimersToNextFrame();
expect(open).not.toHaveBeenCalled();
width = 400;
height = 300;
vi.advanceTimersByTime(FIT_INTERVAL_MS);
expect(open).toHaveBeenCalledTimes(1);
session.dispose();
container.remove();
});
});
Loaded 100 of 149 files, more files were not shown because too many files have changed in this diff. Show more