Compare commits

..
Author SHA1 Message Date
l0ng-ai 56727e3c4b test(i18n): allow CLI to stay untranslated
The New CLI tab added "CLI" to the catalogs; like Shell and other
abbreviations it reads the same in every locale.
2026-11-13 22:26:58 +08:00
350 changed files with 1938 additions and 9033 deletions

No files matched your search

-23
View File
@@ -673,28 +673,6 @@ pub struct AppearanceSettings {
pub status_bar_widgets: BTreeMap<String, bool>,
/// Customize Main Toolbar; `None` = the default toolbar.
pub main_toolbar: Option<MainToolbarSettings>,
/// Appearance › Background Image: an image shown through the IDE window.
pub background_image: BackgroundImageSettings,
}
/// Appearance › Background Image. An empty `path` shows no image.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct BackgroundImageSettings {
pub path: String,
/// Percent, 0–100.
pub opacity: u8,
/// `scale` (cover the window) | `plain` (original size) | `tile`.
pub fill: String,
/// Where the image is anchored: `center`, `top-left`, `top`, `top-right`, `left`, `right`,
/// `bottom-left`, `bottom`, `bottom-right`.
pub anchor: String,
}
impl Default for BackgroundImageSettings {
fn default() -> Self {
Self { path: String::new(), opacity: 15, fill: "scale".into(), anchor: "center".into() }
}
}
/// Main toolbar entries per side, in order: title widget ids (`project`,
@@ -765,7 +743,6 @@ impl Default for AppearanceSettings {
navigation_bar: NavigationBarPosition::StatusBar,
status_bar_widgets: BTreeMap::new(),
main_toolbar: None,
background_image: BackgroundImageSettings::default(),
}
}
}
+1 -7
View File
@@ -34,7 +34,7 @@ use std::path::{Component, Path, PathBuf};
pub const MANIFEST_FILE: &str = "plugin.toml";
/// The plugin API (engine) version this host implements.
pub const ENGINE_VERSION: &str = "1.8.0";
pub const ENGINE_VERSION: &str = "1.7.0";
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
pub const LEGACY_API: &str = "0.1";
/// Marketplace categories, in the order they are shown.
@@ -166,7 +166,6 @@ pub const EVENT_KINDS: &[&str] = &[
"editor-focused",
"action-performed",
"vcs-changed",
"problems-changed",
];
impl std::fmt::Display for ActivationEvent {
@@ -373,11 +372,6 @@ impl Manifest {
{
self.needs_api(&format!("`{event}`"), 6)?;
}
// Added after 1.7.0 shipped: a 1.7 host rejects the manifest, so the gate is the minor
// that carries it.
if matches!(event, ActivationEvent::Event(kind) if kind == "problems-changed") {
self.needs_api(&format!("`{event}`"), 8)?;
}
if matches!(event, ActivationEvent::Event(kind) if matches!(kind.as_str(), "file-closed" | "settings-changed" | "input-mode-changed" | "run-exited" | "terminal-exited" | "content-tab-action"))
{
self.needs_api(&format!("`{event}`"), 4)?;
-78
View File
@@ -1,78 +0,0 @@
//! Host-owned diagnostics snapshot: every `Diagnostics` event lands here, whoever
//! emitted it and whatever window it was addressed to. Plugins read this instead of
//! the UI-reported Problems reports, so what a plugin sees no longer depends on
//! window lifetime, the invoking window's project, or the report loop's liveness.
use ide_core::{Diagnostic, EventSink, IdeEvent, SharedSink};
use parking_lot::RwLock;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
#[derive(Default)]
pub struct DiagnosticsStore(RwLock<BTreeMap<PathBuf, BTreeMap<String, Vec<Diagnostic>>>>);
impl DiagnosticsStore {
/// Full replacement for one (file, producer), like the event itself.
pub fn replace(&self, path: &PathBuf, source: &str, items: Vec<Diagnostic>) {
let mut map = self.0.write();
let sources = map.entry(path.clone()).or_default();
if items.is_empty() {
sources.remove(source);
if sources.is_empty() {
map.remove(path);
}
} else {
sources.insert(source.to_owned(), items);
}
}
/// Every file's diagnostics as one entry per producer.
pub fn problems(&self) -> Vec<(PathBuf, String, Vec<Diagnostic>)> {
let map = self.0.read();
map.iter()
.flat_map(|(path, sources)| {
sources.iter().map(move |(source, items)| (path.clone(), source.clone(), items.clone()))
})
.collect()
}
pub fn file(&self, path: &PathBuf) -> BTreeMap<String, Vec<Diagnostic>> {
self.0.read().get(path).cloned().unwrap_or_default()
}
/// Drops the files under `root` (their project closed; the producers will re-report
/// on open). Returns the number of files removed.
pub fn forget_root(&self, root: &PathBuf) -> usize {
let mut map = self.0.write();
let before = map.len();
map.retain(|path, _| !path.starts_with(root));
before - map.len()
}
}
/// Records `Diagnostics` events, then delivers them as usual.
pub struct RecordingSink {
pub inner: SharedSink,
pub store: Arc<DiagnosticsStore>,
}
impl EventSink for RecordingSink {
fn emit(&self, event: IdeEvent) {
if let IdeEvent::Diagnostics { path, source, items } = &event {
self.store.replace(&path.0.clone(), source, items.clone());
}
self.inner.emit(event);
}
}
/// A sink whose diagnostics are recorded; the store outlives every project.
pub type RecordSinkFactory = Box<dyn Fn(&str) -> SharedSink + Send + Sync>;
/// Wraps a sink factory so every sink it creates records diagnostics.
pub fn recording_sink_factory(store: Arc<DiagnosticsStore>, inner: crate::SinkFactory) -> crate::SinkFactory {
Box::new(move |window| {
let inner = inner(window);
Arc::new(RecordingSink { inner, store: store.clone() }) as SharedSink
})
}
-11
View File
@@ -5,7 +5,6 @@
pub mod agent_secrets;
pub mod data_dirs;
pub mod diagnostics_store;
pub mod environment;
mod formatter;
mod java_runtime;
@@ -59,9 +58,6 @@ pub struct HostConfig {
pub struct Host {
pub(crate) mcp_executable: RwLock<Option<PathBuf>>,
plugin_host: RwLock<std::sync::Weak<plugins::PluginHost>>,
/// Every `Diagnostics` event the host ever emitted, per file and producer:
/// the snapshot plugins read (`problems`), independent of windows and reports.
pub diagnostics: Arc<diagnostics_store::DiagnosticsStore>,
pub mcp_reports: mcp::Reports,
pub mcp_requests: mcp::Requests,
/// Commands agents run in the Run tool window (`run_command`).
@@ -139,16 +135,12 @@ impl Host {
runtime,
project_environment,
} = config;
// Every sink the host hands out records diagnostics into the shared store first.
let diagnostics: Arc<diagnostics_store::DiagnosticsStore> = Arc::default();
let sink_for: SinkFactory = diagnostics_store::recording_sink_factory(diagnostics.clone(), sink_for);
let global_settings = SettingsStore::load(&config_dir, None);
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
ssh.set_configs(global_settings.effective().ssh.configs);
let host = Self {
plugin_host: RwLock::default(),
mcp_executable: RwLock::default(),
diagnostics,
mcp_reports: Default::default(),
mcp_requests: Default::default(),
mcp_agent_runs: Default::default(),
@@ -296,9 +288,6 @@ impl Host {
}
project.files_events_active.store(false, std::sync::atomic::Ordering::Release);
project.plugin_diagnostics.close();
// The producers will re-report when a project reopens; until then the snapshot
// must not answer for files whose project is gone.
self.diagnostics.forget_root(&project.root);
if let Some(plugins) = plugins::editor_plugin_host() {
plugins.invalidate_client_navigation(&project.root, Some(window), None);
plugins.set_active_file(window, None);
-46
View File
@@ -309,13 +309,9 @@ pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos)
pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
let target = position_document(host, window, doc, pos).await?;
// ⌘B on a declaration shows its usages, also when a plugin's index links the name elsewhere
// (a MyBatis mapper method to its XML statement): the server's answer comes along for the
// editor to tell.
if target == doc
&& kind == GotoKind::Definition
&& let Some(plugins) = crate::plugins::editor_plugin_host()
&& !on_declaration_name(host, window, doc, pos).await
{
let project = host.project(window)?;
// A notebook cell is a language-server document only: no plugin navigates in it.
@@ -356,30 +352,6 @@ pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: Got
definition_targets(result, found, in_string)
}
/// Whether the syntax tree puts the identifier at `pos` as a declaration's name (`int f()`'s `f`).
async fn on_declaration_name(host: &Host, window: &str, doc: DocId, pos: Pos) -> bool {
let Ok(project) = host.project(window) else { return false };
blocking(move || {
let snap = project.documents.text_snapshot(doc)?;
if snap.large {
return Ok(false);
}
let s = ide_syntax::Snapshot::doc(doc, snap.version, &snap.language, &snap.text);
let cancel = ide_core::CancellationToken::new();
let Ok(Some(at)) = project.structure.node_at(&s, Range::new(pos, pos), &cancel) else { return Ok(false) };
Ok(is_declaration_name(&at))
})
.await
.unwrap_or(false)
}
fn is_declaration_name(at: &ide_syntax::NodeAt) -> bool {
at.node.field.as_deref() == Some("name")
&& at.ancestors.first().is_some_and(|parent| {
["declaration", "definition", "declarator"].iter().any(|kind| parent.kind.ends_with(kind))
})
}
async fn in_string_literal(host: &Host, window: &str, doc: DocId, pos: Pos) -> bool {
let Ok(project) = host.project(window) else { return false };
blocking(move || {
@@ -2164,24 +2136,6 @@ mod tests {
assert_eq!(paths(definition_targets(Ok(vec![helper, guess]), Vec::new(), false)), ["/p/app/helpers.php"]);
}
#[test]
fn declaration_names_are_told_apart_from_uses() {
let syntax = std::sync::Arc::new(ide_language::Syntax::new());
let structure = ide_syntax::StructureService::new(std::sync::Arc::new(ide_language::SyntaxCache::new(syntax)));
let java = ide_core::LanguageId::new("java");
let text = "interface ShareMapper {\n int batchCancel(List<Long> ids);\n}\nclass S { int f(ShareMapper m) { return m.batchCancel(null); } }\n";
let snap = ide_syntax::Snapshot::text(&java, text);
let cancel = ide_core::CancellationToken::new();
let at = |line, col| {
let pos = Pos::new(line, col);
is_declaration_name(&structure.node_at(&snap, Range::new(pos, pos), &cancel).unwrap().unwrap())
};
assert!(at(1, 10), "the mapper method's name");
assert!(at(0, 12), "the interface's name");
assert!(!at(3, 45), "a call of the method");
assert!(!at(1, 4), "its return type");
}
#[test]
fn positions_inside_string_literals_are_told_apart() {
let syntax = std::sync::Arc::new(ide_language::Syntax::new());
+4 -28
View File
@@ -47,17 +47,14 @@ pub async fn spell_check(
let syntax = host.syntax();
let cancel = host.begin_request(request);
let result = blocking(move || {
let path = project.documents.snapshot(doc)?.path.0.clone();
let snap = project.documents.text_snapshot(doc)?;
if snap.large {
return Ok((path, Vec::new()));
return Ok(Vec::new());
}
let regions = if PROSE.contains(&snap.language.as_str()) {
ide_spell::prose_regions(&snap.text)
} else {
let Some(found) = syntax.spelling_regions(&snap.language, &snap.text) else {
return Ok((path, Vec::new()));
};
let Some(found) = syntax.spelling_regions(&snap.language, &snap.text) else { return Ok(Vec::new()) };
found
.into_iter()
.map(|r| Region {
@@ -75,32 +72,11 @@ pub async fn spell_check(
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let typos =
speller(&project).check(&snap.text, &regions, &|| cancel.is_cancelled()).ok_or_else(IdeError::cancelled)?;
Ok((path, typos))
speller(&project).check(&snap.text, &regions, &|| cancel.is_cancelled()).ok_or_else(IdeError::cancelled)
})
.await;
host.end_request(request);
// Record the typos in the host's diagnostics snapshot so line-end annotations
// see them without waiting for a UI report; the editor still draws from its
// own call above, unchanged.
if let Ok((path, typos)) = &result {
let items: Vec<ide_core::Diagnostic> = typos
.iter()
.map(|t| ide_core::Diagnostic {
range: t.range,
severity: ide_core::Severity::Info,
message: format!("Typo: In word '{}'", t.word),
source: Some("Spelling".into()),
code: None,
unnecessary: false,
deprecated: false,
server: None,
})
.collect();
host.diagnostics.replace(path, "Spelling", items);
}
result.map(|(_, typos)| typos)
result
}
/// Replacements for a misspelt `word`, best first (Typo › Change to…).
+26 -55
View File
@@ -3176,17 +3176,26 @@ impl PluginWorkspace for HostWorkspace {
Ok(out)
}
fn problems(&self, client: &str, path: Option<&Path>) -> Result<Vec<ide_plugin::FileProblems>, HostError> {
// The host records every `Diagnostics` event it emits (language servers,
// builds, spelling, plugin providers), so this sees a file's problems
// whatever window opened it — not just the ones its UI reported.
let host = self.0.upgrade().ok_or(HostError::Cancelled)?;
host.project(client).map_err(failed)?;
let root = host.project(client).map_err(failed)?.root.clone();
let windows = host.windows_for_root(&root);
let mut out = Vec::new();
for (file, source, diagnostics) in host.diagnostics.problems() {
if path.is_some_and(|p| p != file) {
for file in host.mcp_reports.problems(&windows) {
if path.is_some_and(|p| p != file.path.0) {
continue;
}
out.push(ide_plugin::FileProblems { path: to_ipc(&file), source, diagnostics });
if ide_plugin::resolve_new_in_root(&root, &to_ipc(&file.path.0)).is_err() {
continue;
}
let mut sources: BTreeMap<String, Vec<ide_core::Diagnostic>> = BTreeMap::new();
for d in file.items {
sources.entry(d.server.clone().or_else(|| d.source.clone()).unwrap_or_default()).or_default().push(d);
}
out.extend(sources.into_iter().map(|(source, diagnostics)| ide_plugin::FileProblems {
path: to_ipc(&file.path.0),
source,
diagnostics,
}));
}
Ok(out)
}
@@ -3312,8 +3321,6 @@ impl PluginWorkspace for HostWorkspace {
path: &Path,
items: Vec<ide_core::Diagnostic>,
) -> Result<(), HostError> {
// Any open project may publish problems for any file: annotations follow the
// file, not the window (an editor can show a file outside every project root).
let projects = HostWorkspace::projects(self);
let project = projects
.iter()
@@ -3325,8 +3332,7 @@ impl PluginWorkspace for HostWorkspace {
.iter()
.find(|p| crate::project::workspace_member_roots(&p.root).iter().any(|m| path.starts_with(m)))
})
.or_else(|| projects.first())
.ok_or_else(|| HostError::NotFound("no project is open".into()))?;
.ok_or_else(|| HostError::Invalid(format!("{} is not in an open project", path.display())))?;
project.sink.emit(ide_core::IdeEvent::Diagnostics {
path: path.to_path_buf().into(),
source: format!("plugin:{source}"),
@@ -4129,23 +4135,16 @@ impl PluginApi {
return Err(HostError::Invalid("reversed decoration range".into()));
}
let replacement = if let Some(path) = path {
// The decoration follows the file, not the window's project: editors render
// whatever they have open, including files outside every project root. The
// set's root stays the path's own (deepest open) project for tree filtering.
let root = self
.shared
.workspace()
.map(|w| {
w.projects()
.into_iter()
.filter(|root| path.starts_with(root))
.max_by_key(|root| root.components().count())
})
.unwrap_or_default()
.unwrap_or_else(|| path.parent().map(Path::to_path_buf).unwrap_or_else(|| path.to_path_buf()));
let workspace = self.shared.workspace().ok_or_else(|| HostError::NotFound("no project open".into()))?;
let (root, real) = workspace
.projects()
.into_iter()
.filter_map(|root| ide_plugin::resolve_new_in_root(&root, &to_ipc(path)).ok().map(|real| (root, real)))
.max_by_key(|(root, _)| root.components().count())
.ok_or_else(|| HostError::Invalid("path outside open projects".into()))?;
Some(super::DecorationSet {
root: root.into(),
path: ide_core::IpcPath(path.to_path_buf()),
path: real.into(),
set_id: set.into(),
tree,
items,
@@ -4189,8 +4188,6 @@ fn decoration_style(style: &str) -> bool {
| "warning"
| "error"
| "info"
| "typo"
| "weak"
| "underline"
| "strikethrough"
| "muted"
@@ -4311,11 +4308,8 @@ mod workbench_tests {
};
api.decorations(Some(&path), "set", vec![item("highlight"), item("unknown")]).unwrap();
assert_eq!(api.shared.decoration_sets()["test"][0].items.len(), 1);
// Decorations follow the file: a path outside the open project is accepted too.
api.decorations(Some(&root.join("../outside.txt")), "set", vec![]).unwrap();
assert_eq!(api.shared.decoration_sets()["test"].len(), 2);
assert!(api.decorations(Some(&root.join("../outside.txt")), "set", vec![]).is_err());
api.decorations(None, "set", vec![]).unwrap();
assert!(api.shared.decoration_sets()["test"].is_empty());
for i in 0..50 {
api.decorations(Some(&path), &format!("s{i}"), vec![]).unwrap();
}
@@ -4356,29 +4350,6 @@ mod workbench_tests {
api.shared.clear_plugin("test", true);
assert!(api.shared.decoration_sets().is_empty());
}
#[test]
fn decoration_styles_keep_the_severity_palette_the_editor_renders() {
// Inspection Lens colours typos and weak warnings with their own styles; the
// host must keep them, not silently drop them the way an unknown style is dropped.
for style in [
"highlight",
"search",
"added",
"modified",
"removed",
"warning",
"error",
"info",
"typo",
"weak",
"underline",
"strikethrough",
"muted",
] {
assert!(decoration_style(style), "{style} should be a known style");
}
assert!(!decoration_style("unknown"));
}
fn git(root: &Path, args: &[&str]) {
let out = StdCommand::new("git").current_dir(root).args(args).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
+9 -19
View File
@@ -129,7 +129,6 @@ impl PluginHost {
}
let path = match event {
WorkbenchEvent::FileOpened(path) | WorkbenchEvent::FileSaved(path) => Path::new(path),
WorkbenchEvent::EditorFocused(Some(path)) => Path::new(path),
WorkbenchEvent::DocumentChanged(change) => Path::new(&change.path),
_ => return Some(event.clone()),
};
@@ -137,25 +136,16 @@ impl PluginHost {
return None;
}
let workspace = workspace.as_ref()?;
let real = ide_plugin::resolve_real(path);
// The file's own project, not the invoking window's: annotations follow
// the file, so a plugin hears about a file another window has open.
let root = match client {
Some(client) => workspace
.project_of(client)
.filter(|r| real.starts_with(ide_plugin::resolve_real(r)))
.or_else(|| {
workspace
.projects()
.into_iter()
.filter(|r| real.starts_with(ide_plugin::resolve_real(r)))
.max_by_key(|r| r.components().count())
}),
None => workspace
.projects()
.into_iter()
.filter(|r| real.starts_with(ide_plugin::resolve_real(r)))
.max_by_key(|r| r.components().count()),
Some(client) => workspace.project_of(client),
None => {
let real = ide_plugin::resolve_real(path);
workspace
.projects()
.into_iter()
.filter(|r| real.starts_with(ide_plugin::resolve_real(r)))
.max_by_key(|r| r.components().count())
}
}?;
if plugin.dev_root.as_deref().is_some_and(|r| r != root) {
return None;
-26
View File
@@ -352,15 +352,6 @@ where
deserializer.deserialize_map(Properties)
}
/// A sample shown in a setting's own style (the Colour Scheme colours), e.g. the
/// message an Inspection Lens severity produces.
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
pub struct SettingExample {
pub text: String,
/// `error`, `warning`, `weak`, `typo`, `info` or `muted`: which sample style to draw.
pub style: String,
}
/// One setting of a plugin.
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -380,8 +371,6 @@ pub struct SettingSchema {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub example: Option<SettingExample>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub items: Option<Box<SettingSchema>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub properties: Vec<SettingSchema>,
@@ -466,20 +455,6 @@ fn setting_at(key: &str, raw: &serde_json::value::RawValue, depth: u8) -> Result
Some(_) => Err(format!("setting {key:?}: `{name}` is a string up to {max} bytes")),
}
};
let example = match o.get("example") {
None => None,
Some(v) => {
let e: SettingExample = serde_json::from_value(v.clone())
.map_err(|e| format!("setting {key:?}: `example` is {{\"text\", \"style\"}}: {e}"))?;
if e.text.is_empty() || e.text.len() > 300 {
return Err(format!("setting {key:?}: `example.text` is 1–300 bytes"));
}
if e.style.len() > 32 || !e.style.chars().all(|c| c.is_ascii_lowercase()) {
return Err(format!("setting {key:?}: `example.style` is a lowercase style name"));
}
Some(e)
}
};
let s = SettingSchema {
key: key.to_owned(),
kind: kind.to_owned(),
@@ -489,7 +464,6 @@ fn setting_at(key: &str, raw: &serde_json::value::RawValue, depth: u8) -> Result
options,
multiline: o.get("multiline").and_then(Value::as_bool).unwrap_or(false),
group: text("group", MAX_TITLE)?,
example,
items,
properties,
};
@@ -430,61 +430,3 @@ async fn typing_is_debounced_versioned_and_scoped_to_document_project_and_window
host.shutdown().await;
set_editor_providers(None);
}
/// A host with a project open: `problems` and `decorations` need a workspace.
fn harness(root: &Path) -> Arc<Host> {
let host = Arc::new(Host::new(HostConfig {
config_dir: root.join("config"),
data_dir: root.join("data"),
local_data_dir: root.join("local"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.open_project("main", root).unwrap();
host
}
fn plugins_on(host: &Arc<Host>, root: &Path) -> PluginHost {
let plugins = PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), generous()).unwrap();
plugins.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(host)))));
plugins.install_from_dir(&fixture(), &Consent::all()).unwrap();
plugins
}
#[tokio::test(flavor = "multi_thread")]
async fn a_plugin_reads_back_the_diagnostics_the_host_recorded() {
let tmp = tempfile::tempdir().unwrap();
let host = harness(tmp.path());
let plugins = plugins_on(&host, tmp.path());
let path = tmp.path().join("a.txt");
std::fs::write(&path, "text").unwrap();
// `publish` goes through the host's recording sink into the snapshot a plugin reads.
let invocation = || Invocation { client: Some("main".into()), ..Invocation::default() };
plugins.run_command(ID, "publish", Some(path.to_str().unwrap()), invocation()).unwrap();
let problems = plugins.run_command(ID, "problems", None, invocation()).unwrap().unwrap();
assert!(problems.contains("a.txt"), "problems returned {problems:?}");
assert!(problems.ends_with(":1"), "one diagnostic from its producer, got {problems:?}");
}
#[tokio::test(flavor = "multi_thread")]
async fn a_line_end_annotation_takes_the_typo_and_weak_styles() {
let tmp = tempfile::tempdir().unwrap();
let host = harness(tmp.path());
let plugins = plugins_on(&host, tmp.path());
let path = tmp.path().join("a.txt");
std::fs::write(&path, "text").unwrap();
// `decorate` publishes both styles; the host drops an unknown style silently, so a
// rejection here means one of the two is not a known decoration style.
plugins
.run_command(
ID,
"decorate",
Some(path.to_str().unwrap()),
Invocation { client: Some("main".into()), ..Invocation::default() },
)
.unwrap();
}
+10 -19
View File
@@ -139,7 +139,7 @@ async fn text_search_uses_overlay_excludes_utf16_and_limits() {
}
#[tokio::test(flavor = "multi_thread")]
async fn problems_come_from_the_host_snapshot_whatever_window_published_them() {
async fn problems_share_project_reports_and_keep_sources_separate() {
let f = Harness::new();
let path = f.root.join("a.txt");
std::fs::write(&path, "text").unwrap();
@@ -153,30 +153,21 @@ async fn problems_come_from_the_host_snapshot_whatever_window_published_them() {
deprecated: false,
server: None,
};
// Producers publish per (file, producer) through the event sink, like the
// language servers and the spelling check do.
let publish = |host: &Host, source: &str, items: Vec<Diagnostic>| {
host.project("main").unwrap().sink.emit(ide_core::IdeEvent::Diagnostics {
f.host.mcp_reports.report_problems(
"second",
true,
vec![ide_host::mcp::FileProblems {
path: path.clone().into(),
source: source.into(),
items,
});
};
publish(&f.host, "rust-analyzer", vec![diagnostic("lsp")]);
publish(&f.host, "build", vec![diagnostic("plugin")]);
// A file outside the window's project is visible too: annotations follow the file.
let outside = f.host.diagnostics.problems().len();
items: vec![diagnostic("lsp"), diagnostic("plugin")],
}],
);
let workspace = f.workspace();
let files = workspace.problems("main", Some(&path)).unwrap();
assert_eq!(files.len(), 2);
assert!(files.iter().all(|f| f.diagnostics.len() == 1));
assert!(workspace.problems("main", Some(&f.root.join("other.txt"))).unwrap().is_empty());
assert_eq!(outside, 2);
// A full replacement per producer clears only that producer.
publish(&f.host, "rust-analyzer", vec![]);
let files = workspace.problems("main", None).unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].source, "build");
f.host.mcp_reports.forget_window("second");
assert!(workspace.problems("main", None).unwrap().is_empty());
}
#[tokio::test(flavor = "multi_thread")]
+2 -8
View File
@@ -2554,8 +2554,7 @@ fn official_git_toolbox_fetches_on_schedule_after_idle_reclaim() {
f.install(&official("git-toolbox"));
// Focusing an editor wakes it to show the branch (nothing fetched yet).
let now = Instant::now();
let focused = f.project.join("src/notes.txt").to_string_lossy().into_owned();
f.host.post_events(Some("main"), vec![WorkbenchEvent::EditorFocused(Some(focused))]);
f.host.post_events(Some("main"), vec![WorkbenchEvent::EditorFocused(Some("src/notes.txt".into()))]);
f.host.flush_events_at(now + Duration::from_secs(3));
assert!(f.host.wait_events(Duration::from_secs(10)));
let item = |host: &PluginHost| {
@@ -2993,18 +2992,13 @@ fn official_csv_editor_edits_the_shared_document() {
f.host.view_event("openidea.csv-editor", "table", cell, inv.clone()).unwrap();
assert_eq!(f.workspace.text("people.csv"), "name,city\nAda,\"Paris, FR\"\nBob,Oslo\n");
// Add Row appends, also on wasm32 (where its row index used to wrap to the header).
let add = ide_plugin::ViewEvent::Button { id: "add-row".into() };
f.host.view_event("openidea.csv-editor", "table", add, inv.clone()).unwrap();
assert_eq!(f.workspace.text("people.csv"), "name,city\nAda,\"Paris, FR\"\nBob,Oslo\n,\n");
// Settings: without a header row every record is a row.
let mut values = serde_json::Map::new();
values.insert("firstRowIsHeader".into(), serde_json::Value::Bool(false));
f.workspace.settings.lock().insert("openidea.csv-editor".into(), values);
let view = f.host.render_view("openidea.csv-editor", "table", None, inv.clone()).unwrap();
let ide_plugin::ViewBody::Table(t) = &view.body else { panic!("{view:?}") };
assert_eq!((t.columns[0].as_str(), t.total_rows), ("Column 1", 4));
assert_eq!((t.columns[0].as_str(), t.total_rows), ("Column 1", 3));
// Undeclared views are refused before any code runs.
let err = f.host.render_view("openidea.csv-editor", "other", None, inv).unwrap_err();
+77 -676
View File
@@ -1,13 +1,4 @@
//! In-memory index: one slot per file, with compact query-time tables derived from it.
//!
//! Queries never walk the fat `FileEntry` structs: `HotFiles`, `HotDirs` and `SymTable` hold
//! only the fields a scan needs, packed in dense rows with interned strings, so a query
//! streams contiguous memory instead of chasing `Box<[Sym]>` / `Box<str>` pointers.
//! They are plain data, updated eagerly by `insert`/`remove` (every mutation needs `&mut
//! IndexData`), and all queries run under the `IndexData` read lock: a scan therefore sees
//! one consistent snapshot, and the only "invalidation" is the update itself. Space left
//! behind by replaced/removed entries is reclaimed by compaction inside `insert`/`remove`
//! once it dominates the table, and by `shrink`.
//! In-memory index: one slot per file, symbols stored with their file.
use ide_core::{Range, SymbolKind};
use serde::{Deserialize, Serialize};
@@ -42,40 +33,6 @@ pub(crate) fn mask_bit(b: u8) -> u64 {
}
}
/// A substring of an interned text arena.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct Span {
pub start: u32,
pub len: u32,
}
impl Span {
/// A slice of nothing; used for optional fields (`None` containers).
pub(crate) const NONE: Span = Span { start: u32::MAX, len: 0 };
/// Appends `s` to `arena`, returning the span it will occupy.
///
/// Spans are `u32`-addressed and `NONE` uses `u32::MAX` as its start, so an
/// arena that reaches that bound cannot be represented. Fail loudly here
/// instead of truncating the offsets: a wrapped span would make [`Self::get`]
/// slice unrelated text, or panic far from the actual cause.
#[inline]
fn of(arena: &str, s: &str) -> Self {
let start = u32::try_from(arena.len()).expect("index text arena exceeded the 4 GiB span limit");
let len = u32::try_from(s.len()).expect("index text span exceeded the 4 GiB span limit");
assert!(
start != u32::MAX && start.checked_add(len).is_some(),
"index text arena exceeded the 4 GiB span limit"
);
Self { start, len }
}
#[inline]
pub(crate) fn get(self, arena: &str) -> &str {
&arena[self.start as usize..(self.start + self.len) as usize]
}
}
/// Persisted per-file record (postcard in redb, keyed by the relative path).
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub(crate) struct FileRecord {
@@ -95,12 +52,12 @@ pub(crate) struct StoredSymbol {
pub range: Range,
}
/// A symbol resolved out of [`SymTable`].
pub(crate) struct SymRef<'a> {
pub name: &'a str,
pub(crate) struct Sym {
pub name: Box<str>,
pub kind: SymbolKind,
pub container: Option<&'a str>,
pub container: Option<Box<str>>,
pub range: Range,
pub mask: u64,
}
pub(crate) struct FileEntry {
@@ -116,34 +73,38 @@ pub(crate) struct FileEntry {
pub mtime: u64,
pub size: u64,
pub hash: [u8; 32],
/// Range of this file's symbols in [`IndexData::syms`].
pub sym_start: u32,
pub sym_len: u32,
pub symbols: Box<[Sym]>,
pub ignored: bool,
}
impl FileEntry {
/// Splits the record into the entry and its symbols; the symbols are flattened into
/// the index-wide [`SymTable`] by [`IndexData::insert`].
pub(crate) fn new(path: PathBuf, rel: String, record: FileRecord) -> (Self, Vec<StoredSymbol>) {
pub(crate) fn new(path: PathBuf, rel: String, record: FileRecord) -> Self {
let name_start = rel.rfind('/').map_or(0, |i| i + 1);
let name = &rel[name_start..];
let FileRecord { mtime, size, hash, symbols, ignored } = record;
let entry = Self {
let symbols = record
.symbols
.into_iter()
.map(|s| Sym {
mask: char_mask(&s.name),
name: s.name.into_boxed_str(),
kind: s.kind,
container: s.container.map(String::into_boxed_str),
range: s.range,
})
.collect();
Self {
name_start_chars: rel[..name_start].chars().count() as u32,
name_mask: char_mask(name),
rel_mask: char_mask(&rel),
name_start: name_start as u32,
path,
rel: rel.into_boxed_str(),
mtime,
size,
hash,
sym_start: 0,
sym_len: 0,
ignored,
};
(entry, symbols)
mtime: record.mtime,
size: record.size,
hash: record.hash,
symbols,
ignored: record.ignored,
}
}
pub(crate) fn name(&self) -> &str {
@@ -154,391 +115,38 @@ impl FileEntry {
&self.rel[..self.name_start as usize]
}
pub(crate) fn record(&self) -> FileRecord {
FileRecord {
mtime: self.mtime,
size: self.size,
hash: self.hash,
symbols: self
.symbols
.iter()
.map(|s| StoredSymbol {
name: s.name.to_string(),
kind: s.kind,
container: s.container.as_deref().map(str::to_owned),
range: s.range,
})
.collect(),
ignored: self.ignored,
}
}
/// Whether the entry is out of date for `found` on disk.
pub(crate) fn stale_for(&self, found: &crate::filter::Found) -> bool {
self.mtime != found.mtime || self.size != found.size || self.ignored != found.ignored
}
}
/// The hot fields of one file slot, packed for the query scan; no pointers.
pub(crate) struct FileRow {
/// `rel` in [`HotFiles`]' arena.
pub rel: Span,
/// Byte offset of the file name inside `rel`.
pub name_start: u32,
pub sym_start: u32,
pub sym_len: u32,
/// Union of every symbol's [`char_mask`]: a selective symbol query can skip
/// the whole file before touching its symbol range.
pub sym_mask: u64,
pub rel_mask: u64,
pub name_mask: u64,
/// Mask of the directory part (`rel[..name_start]`), precomputed for path queries.
pub dir_mask: u64,
/// `rel` is pure ASCII, so nucleo can match it without a UTF-32 conversion.
pub ascii: bool,
/// The slot holds a file; free slots keep a row so scans line up with `IndexData::files`.
pub live: bool,
}
impl FileRow {
#[inline]
pub(crate) fn rel<'a>(&self, arena: &'a str) -> &'a str {
self.rel.get(arena)
}
#[inline]
pub(crate) fn name<'a>(&self, arena: &'a str) -> &'a str {
&self.rel.get(arena)[self.name_start as usize..]
}
#[inline]
pub(crate) fn dir<'a>(&self, arena: &'a str) -> &'a str {
&self.rel.get(arena)[..self.name_start as usize]
}
}
/// One dense row per file slot (same order and length as `IndexData::files`) plus the
/// text arena the rows point into.
#[derive(Default)]
pub(crate) struct HotFiles {
pub rows: Vec<FileRow>,
text: String,
dead_text: usize,
}
impl HotFiles {
#[inline]
pub(crate) fn text(&self) -> &str {
&self.text
}
#[inline]
pub(crate) fn row(&self, slot: usize) -> Option<&FileRow> {
self.rows.get(slot).filter(|row| row.live)
}
#[inline]
pub(crate) fn row_of_slot(&self, slot: u32) -> Option<&FileRow> {
self.row(slot as usize)
}
/// `rel` of a live slot; candidate slots always are.
#[inline]
pub(crate) fn rel(&self, slot: u32) -> &str {
self.rows[slot as usize].rel(&self.text)
}
fn set(&mut self, slot: u32, row: FileRow) {
if self.rows.len() as u32 == slot {
self.rows.push(row);
} else {
self.rows[slot as usize] = row;
}
}
fn append(&mut self, rel: &str) -> Span {
let span = Span::of(&self.text, rel);
self.text.push_str(rel);
span
}
/// Updates the row of an existing slot (replace) in place.
fn update(&mut self, slot: u32, e: &FileEntry, sym_mask: u64) {
let idx = slot as usize;
if self.rows[idx].rel.get(&self.text) != &*e.rel {
self.dead_text += self.rows[idx].rel.len as usize;
let span = self.append(&e.rel);
self.rows[idx].rel = span;
self.rows[idx].ascii = e.rel.is_ascii();
self.rows[idx].dir_mask = char_mask(e.dir());
}
let row = &mut self.rows[idx];
row.name_start = e.name_start;
row.name_mask = e.name_mask;
row.rel_mask = e.rel_mask;
row.sym_start = e.sym_start;
row.sym_len = e.sym_len;
row.sym_mask = sym_mask;
row.live = true;
}
fn remove(&mut self, slot: u32) {
let row = &mut self.rows[slot as usize];
self.dead_text += row.rel.len as usize;
row.rel = Span::NONE;
row.sym_start = 0;
row.sym_len = 0;
row.sym_mask = 0;
row.live = false;
}
fn compact(&mut self) {
let mut text = String::with_capacity(self.text.len() - self.dead_text);
for row in &mut self.rows {
if !row.live {
continue;
}
let rel = row.rel.get(&self.text);
row.rel = Span::of(&text, rel);
text.push_str(rel);
}
self.text = text;
self.dead_text = 0;
}
fn shrink(&mut self) {
self.compact();
self.rows.shrink_to_fit();
self.text.shrink_to_fit();
}
}
/// One folder holding indexed files, packed for the query scan.
pub(crate) struct DirRow {
/// `rel` in [`HotDirs`]' arena, including the trailing `/`.
pub rel: Span,
/// Byte offset of the last path segment inside the trimmed `rel`.
pub name_start: u32,
pub name_mask: u64,
/// Mask of `rel` without the trailing `/`.
pub rel_mask: u64,
pub path: PathBuf,
}
impl DirRow {
#[inline]
pub(crate) fn rel<'a>(&self, arena: &'a str) -> &'a str {
self.rel.get(arena)
}
}
#[derive(Default)]
pub(crate) struct HotDirs {
pub rows: Vec<DirRow>,
text: String,
dead: usize,
}
impl HotDirs {
#[inline]
pub(crate) fn text(&self) -> &str {
&self.text
}
fn push(&mut self, rel: &str, abs: &Path) -> u32 {
let span = Span::of(&self.text, rel);
self.text.push_str(rel);
let trimmed = &rel[..rel.len() - 1];
let name_start = trimmed.rfind('/').map_or(0, |i| i + 1);
self.rows.push(DirRow {
rel: span,
name_start: name_start as u32,
name_mask: char_mask(&trimmed[name_start..]),
rel_mask: char_mask(trimmed),
path: abs.to_owned(),
});
(self.rows.len() - 1) as u32
}
fn remove(&mut self, row: u32, dirs: &mut HashMap<Box<str>, (PathBuf, u32, u32)>) {
let idx = row as usize;
let last = self.rows.len() - 1;
self.dead += self.rows[idx].rel.len as usize;
self.rows.swap_remove(idx);
if idx != last {
let moved = self.rows[idx].rel.get(&self.text);
if let Some(dir) = dirs.get_mut(moved) {
dir.2 = idx as u32;
}
}
}
fn compact(&mut self) {
let mut text = String::with_capacity(self.text.len() - self.dead);
for row in &mut self.rows {
let rel = row.rel.get(&self.text);
row.rel = Span::of(&text, rel);
text.push_str(rel);
}
self.text = text;
self.dead = 0;
}
fn shrink(&mut self) {
self.compact();
self.rows.shrink_to_fit();
self.text.shrink_to_fit();
}
}
/// The scan-relevant fields of one symbol: its character mask and the name in the
/// arena. Interleaved so a scan touches one cache line per candidate instead of three
/// parallel arrays.
#[derive(Clone, Copy)]
pub(crate) struct SymHead {
pub mask: u64,
pub name: Span,
}
/// Index-wide symbol storage: one row per symbol in parallel arrays, with names and
/// containers interned in a single arena. Each file points at a contiguous range.
#[derive(Default)]
pub(crate) struct SymTable {
head: Vec<SymHead>,
container: Vec<Span>,
kind: Vec<SymbolKind>,
range: Vec<Range>,
text: String,
dead_rows: usize,
}
impl SymTable {
#[inline]
pub(crate) fn name(&self, i: u32) -> &str {
self.head[i as usize].name.get(&self.text)
}
#[inline]
pub(crate) fn mask(&self, i: u32) -> u64 {
self.head[i as usize].mask
}
#[inline]
pub(crate) fn container(&self, i: u32) -> Option<&str> {
let span = self.container[i as usize];
(span.start != u32::MAX).then(|| span.get(&self.text))
}
/// The mask + name rows of a file's contiguous symbol range.
#[inline]
pub(crate) fn heads(&self, start: u32, len: u32) -> &[SymHead] {
let (start, end) = (start as usize, start as usize + len as usize);
&self.head[start..end]
}
#[inline]
pub(crate) fn kind(&self, i: u32) -> SymbolKind {
self.kind[i as usize]
}
#[inline]
pub(crate) fn range(&self, i: u32) -> Range {
self.range[i as usize]
}
fn intern(&mut self, s: &str) -> Span {
let span = Span::of(&self.text, s);
self.text.push_str(s);
span
}
/// Appends a file's symbols; they stay contiguous. Returns the start index
/// and the union of their masks.
fn push(&mut self, symbols: &[StoredSymbol]) -> (u32, u64) {
let start = self.head.len() as u32;
let mut agg_mask = 0u64;
for s in symbols {
let mask = char_mask(&s.name);
agg_mask |= mask;
let name = self.intern(&s.name);
let container = match &s.container {
Some(c) => self.intern(c),
None => Span::NONE,
};
self.head.push(SymHead { mask, name });
self.container.push(container);
self.kind.push(s.kind);
self.range.push(s.range);
}
(start, agg_mask)
}
/// Whether `symbols` would flatten to exactly the range at `start`.
fn same(&self, start: u32, len: u32, symbols: &[StoredSymbol]) -> bool {
len as usize == symbols.len()
&& symbols.iter().enumerate().all(|(k, s)| {
let i = start + k as u32;
self.name(i) == s.name
&& self.container(i) == s.container.as_deref()
&& self.kind(i) == s.kind
&& self.range(i) == s.range
})
}
fn free(&mut self, _start: u32, len: u32) {
self.dead_rows += len as usize;
}
/// Rewrites the live rows in file order, reclaiming the space of replaced files.
/// `files` entries keep their range in sync; queries read rows, but persistence
/// and `FileEntry` accessors go through the entry.
fn compact(&mut self, rows: &mut [FileRow], files: &mut [Option<FileEntry>]) {
let mut text = String::with_capacity(self.text.len());
let mut head = Vec::with_capacity(self.head.len() - self.dead_rows);
let mut container = Vec::with_capacity(head.capacity());
let mut kind = Vec::with_capacity(head.capacity());
let mut range = Vec::with_capacity(head.capacity());
let mut live = 0u32;
for (slot, row) in rows.iter_mut().enumerate() {
if !row.live {
row.sym_start = 0;
continue;
}
let (start, end) = (row.sym_start, row.sym_start + row.sym_len);
row.sym_start = live;
if let Some(e) = files.get_mut(slot).and_then(Option::as_mut) {
e.sym_start = live;
}
for i in start..end {
let name_span = self.head[i as usize].name;
let name = name_span.get(&self.text);
head.push(SymHead { mask: self.head[i as usize].mask, name: Span::of(&text, name) });
text.push_str(name);
let container_span = self.container[i as usize];
if container_span.start == u32::MAX {
container.push(Span::NONE);
} else {
let c = container_span.get(&self.text);
container.push(Span::of(&text, c));
text.push_str(c);
}
kind.push(self.kind[i as usize]);
range.push(self.range[i as usize]);
live += 1;
}
}
self.head = head;
self.container = container;
self.kind = kind;
self.range = range;
self.text = text;
self.dead_rows = 0;
}
fn shrink(&mut self) {
self.head.shrink_to_fit();
self.container.shrink_to_fit();
self.kind.shrink_to_fit();
self.range.shrink_to_fit();
self.text.shrink_to_fit();
}
}
#[derive(Default)]
pub(crate) struct IndexData {
pub files: Vec<Option<FileEntry>>,
by_path: HashMap<PathBuf, u32>,
/// Every folder holding an indexed file at any depth, keyed by its `rel` with a
/// trailing `/`: its absolute path, the number of files below it, and its row in
/// [`HotDirs`].
pub dirs: HashMap<Box<str>, (PathBuf, u32, u32)>,
/// Compact query-time view of `files`, kept in sync by `insert`/`remove`.
pub hot: HotFiles,
/// Compact query-time view of `dirs`, kept in sync by `insert`/`remove`.
pub dirs_hot: HotDirs,
/// Flattened symbols of every file, kept in sync by `insert`/`remove`.
pub syms: SymTable,
/// trailing `/`: its absolute path and the number of files below it.
pub dirs: HashMap<Box<str>, (PathBuf, u32)>,
free: Vec<u32>,
pub file_count: u32,
pub symbol_count: u32,
@@ -553,124 +161,49 @@ impl IndexData {
self.by_path.get(path).copied()
}
/// The persisted form of a file, rebuilt from [`IndexData::syms`].
pub(crate) fn record(&self, e: &FileEntry) -> FileRecord {
FileRecord {
mtime: e.mtime,
size: e.size,
hash: e.hash,
symbols: self
.symbols_of(e)
.map(|s| StoredSymbol {
name: s.name.to_owned(),
kind: s.kind,
container: s.container.map(str::to_owned),
range: s.range,
})
.collect(),
ignored: e.ignored,
}
}
pub(crate) fn record_of(&self, path: &Path) -> Option<FileRecord> {
self.get(path).map(|e| self.record(e))
}
/// The symbols of one file, in declaration order.
pub(crate) fn symbols_of<'a>(&'a self, e: &'a FileEntry) -> impl Iterator<Item = SymRef<'a>> + 'a {
let syms = &self.syms;
(e.sym_start..e.sym_start + e.sym_len).map(move |i| SymRef {
name: syms.name(i),
kind: syms.kind(i),
container: syms.container(i),
range: syms.range(i),
})
}
pub(crate) fn insert(&mut self, mut entry: FileEntry, symbols: Vec<StoredSymbol>) {
let sym_len = symbols.len() as u32;
pub(crate) fn insert(&mut self, entry: FileEntry) {
let symbols = entry.symbols.len() as u32;
if let Some(&slot) = self.by_path.get(&entry.path) {
let old = self.files[slot as usize].as_ref().expect("indexed slots are live");
let old_len = old.sym_len;
// Rescans and saves that re-parse to the same symbols reuse the range; a
// same-path replacement then costs nothing in the symbol table.
let (sym_start, sym_mask) = if self.syms.same(old.sym_start, old_len, &symbols) {
(old.sym_start, self.hot.rows[slot as usize].sym_mask)
} else {
self.syms.free(old.sym_start, old_len);
self.syms.push(&symbols)
};
entry.sym_start = sym_start;
entry.sym_len = sym_len;
self.symbol_count = self.symbol_count - old_len + sym_len;
self.hot.update(slot, &entry, sym_mask);
self.files[slot as usize] = Some(entry);
self.compact_if_needed();
let old = self.files[slot as usize].replace(entry);
self.symbol_count -= old.map_or(0, |o| o.symbols.len() as u32);
self.symbol_count += symbols;
return;
}
let (sym_start, sym_mask) = self.syms.push(&symbols);
entry.sym_start = sym_start;
entry.sym_len = sym_len;
let slot = match self.free.pop() {
Some(slot) => slot,
Some(slot) => {
self.files[slot as usize] = Some(entry);
slot
}
None => {
self.files.push(None);
self.files.push(Some(entry));
(self.files.len() - 1) as u32
}
};
let path = entry.path.clone();
let (name_start, name_mask, rel_mask) = (entry.name_start, entry.name_mask, entry.rel_mask);
let rel = self.hot.append(&entry.rel);
self.hot.set(
slot,
FileRow {
rel,
name_start,
sym_start: entry.sym_start,
sym_len,
sym_mask,
rel_mask,
name_mask,
dir_mask: char_mask(&entry.rel[..name_start as usize]),
ascii: entry.rel.is_ascii(),
live: true,
},
);
self.files[slot as usize] = Some(entry);
let path = self.files[slot as usize].as_ref().map(|e| e.path.clone()).unwrap_or_default();
if let Some(e) = &self.files[slot as usize] {
for (rel, abs) in ancestors(e) {
if let Some(dir) = self.dirs.get_mut(rel) {
dir.1 += 1;
} else {
let row = self.dirs_hot.push(rel, abs);
self.dirs.insert(rel.into(), (abs.to_owned(), 1, row));
}
self.dirs.entry(rel.into()).or_insert_with(|| (abs.to_owned(), 0)).1 += 1;
}
}
self.by_path.insert(path, slot);
self.file_count += 1;
self.symbol_count += sym_len;
self.compact_if_needed();
self.symbol_count += symbols;
}
pub(crate) fn remove(&mut self, path: &Path) -> Option<FileEntry> {
let slot = self.by_path.remove(path)?;
let old = self.files[slot as usize].take()?;
self.free.push(slot);
self.hot.remove(slot);
self.syms.free(old.sym_start, old.sym_len);
for (rel, _) in ancestors(&old) {
let Some(dir) = self.dirs.get_mut(rel) else { continue };
dir.1 -= 1;
if dir.1 > 0 {
continue;
if let Some(d) = self.dirs.get_mut(rel) {
d.1 -= 1;
if d.1 == 0 {
self.dirs.remove(rel);
}
}
let Some((_, _, row)) = self.dirs.remove(rel) else { continue };
self.dirs_hot.remove(row, &mut self.dirs);
}
self.file_count -= 1;
self.symbol_count -= old.sym_len;
self.compact_if_needed();
self.symbol_count -= old.symbols.len() as u32;
Some(old)
}
@@ -680,19 +213,6 @@ impl IndexData {
doomed.into_iter().filter(|p| self.remove(p).is_some()).collect()
}
/// Reclaims the space of replaced/removed files once it dominates a table.
fn compact_if_needed(&mut self) {
if self.hot.dead_text > 1 << 16 && self.hot.dead_text * 2 > self.hot.text.len() {
self.hot.compact();
}
if self.syms.dead_rows > 4096 && self.syms.dead_rows * 2 > self.syms.head.len() {
self.syms.compact(&mut self.hot.rows, &mut self.files);
}
if self.dirs_hot.dead > 1 << 16 && self.dirs_hot.dead * 2 > self.dirs_hot.text.len() {
self.dirs_hot.compact();
}
}
/// Releases spare capacity (after large deletions).
pub(crate) fn shrink(&mut self) {
while matches!(self.files.last(), Some(None)) {
@@ -701,11 +221,6 @@ impl IndexData {
self.free.retain(|&s| s != last);
}
self.files.shrink_to_fit();
self.hot.rows.truncate(self.files.len());
self.hot.shrink();
self.dirs_hot.shrink();
self.syms.compact(&mut self.hot.rows, &mut self.files);
self.syms.shrink();
self.free.shrink_to_fit();
self.by_path.shrink_to_fit();
self.dirs.shrink_to_fit();
@@ -733,7 +248,7 @@ fn ancestors(e: &FileEntry) -> impl Iterator<Item = (&str, &Path)> {
mod tests {
use super::*;
fn entry(path: &str, symbols: usize) -> (FileEntry, Vec<StoredSymbol>) {
fn entry(path: &str, symbols: usize) -> FileEntry {
let record = FileRecord {
symbols: (0..symbols)
.map(|i| StoredSymbol {
@@ -748,101 +263,48 @@ mod tests {
FileEntry::new(PathBuf::from("/r").join(path), path.to_owned(), record)
}
fn insert(d: &mut IndexData, path: &str, symbols: usize) {
let (e, s) = entry(path, symbols);
d.insert(e, s);
}
/// The compact tables must always mirror the entries they were derived from.
fn assert_consistent(d: &IndexData) {
assert_eq!(d.hot.rows.len(), d.files.len(), "one hot row per slot");
assert_eq!(d.hot.rows.iter().filter(|r| r.live).count(), d.file_count as usize, "live rows are files");
for (slot, e) in d.files.iter().enumerate() {
let Some(e) = e else {
assert!(d.hot.row(slot).is_none(), "free slot {slot} has no row");
continue;
};
let row = d.hot.row(slot).unwrap_or_else(|| panic!("slot {slot} has a row"));
assert_eq!(row.rel(d.hot.text()), &*e.rel);
assert_eq!(row.name(d.hot.text()), e.name());
assert_eq!(row.dir(d.hot.text()), e.dir());
assert_eq!(row.name_mask, char_mask(e.name()));
assert_eq!(row.rel_mask, char_mask(&e.rel));
assert_eq!(row.dir_mask, char_mask(e.dir()));
assert_eq!(row.ascii, e.rel.is_ascii());
assert_eq!(row.sym_start, e.sym_start);
assert_eq!(row.sym_len, e.sym_len);
assert_eq!(row.sym_mask, d.symbols_of(e).fold(0u64, |m, s| m | char_mask(s.name)));
assert_eq!(d.syms.head.len(), d.syms.container.len());
assert_eq!(d.syms.head.len(), d.syms.kind.len());
assert_eq!(d.syms.head.len(), d.syms.range.len());
assert!(e.sym_len == 0 || (e.sym_start as usize) < d.syms.head.len());
assert!(e.sym_start + e.sym_len <= d.syms.head.len() as u32);
}
for (rel, (path, count, row)) in &d.dirs {
let row = &d.dirs_hot.rows[*row as usize];
assert_eq!(row.rel(d.dirs_hot.text()), &**rel);
assert_eq!(&row.path, path);
assert!(*count > 0);
let trimmed = &rel[..rel.len() - 1];
let name = &trimmed[row.name_start as usize..];
assert_eq!(row.name_mask, char_mask(name));
assert_eq!(row.rel_mask, char_mask(trimmed));
}
assert_eq!(d.dirs.len(), d.dirs_hot.rows.len());
assert_eq!(d.symbol_count, d.files.iter().flatten().map(|e| e.sym_len).sum::<u32>());
}
#[test]
fn dirs_follow_inserts_and_removals() {
let mut d = IndexData::default();
insert(&mut d, "a/b/c.rs", 0);
insert(&mut d, "a/d.rs", 0);
insert(&mut d, "a/d.rs", 0);
insert(&mut d, "top.rs", 0);
d.insert(entry("a/b/c.rs", 0));
d.insert(entry("a/d.rs", 0));
d.insert(entry("a/d.rs", 0));
d.insert(entry("top.rs", 0));
let mut dirs: Vec<_> = d.dirs.iter().map(|(k, v)| (k.to_string(), v.1)).collect();
dirs.sort();
assert_eq!(dirs, [("a/".into(), 2), ("a/b/".into(), 1)]);
assert_eq!(d.dirs["a/b/"].0, PathBuf::from("/r/a/b"));
assert_consistent(&d);
d.remove(Path::new("/r/a/b/c.rs"));
assert!(!d.dirs.contains_key("a/b/"));
assert_consistent(&d);
d.remove_tree(Path::new("/r/a"));
assert!(d.dirs.is_empty());
assert_consistent(&d);
}
#[test]
fn insert_replace_remove_keep_counts() {
let mut d = IndexData::default();
insert(&mut d, "a/b.rs", 2);
insert(&mut d, "c.rs", 1);
d.insert(entry("a/b.rs", 2));
d.insert(entry("c.rs", 1));
assert_eq!((d.file_count, d.symbol_count), (2, 3));
insert(&mut d, "a/b.rs", 5);
d.insert(entry("a/b.rs", 5));
assert_eq!((d.file_count, d.symbol_count), (2, 6));
assert_consistent(&d);
assert!(d.remove(Path::new("/r/c.rs")).is_some());
assert_eq!((d.file_count, d.symbol_count), (1, 5));
assert_consistent(&d);
insert(&mut d, "d.rs", 0);
d.insert(entry("d.rs", 0));
assert_eq!(d.files.len(), 2, "slot reused");
assert_consistent(&d);
assert_eq!(d.remove_tree(Path::new("/r/a")), vec![PathBuf::from("/r/a/b.rs")]);
assert_eq!((d.file_count, d.symbol_count), (1, 0));
let e = d.get(Path::new("/r/d.rs")).unwrap();
assert_eq!((e.name(), e.dir()), ("d.rs", ""));
insert(&mut d, "e.rs", 1);
insert(&mut d, "f.rs", 1);
d.insert(entry("e.rs", 1));
d.insert(entry("f.rs", 1));
d.remove(Path::new("/r/e.rs"));
d.remove(Path::new("/r/f.rs"));
d.shrink();
assert_consistent(&d);
assert_eq!(d.files.len(), 2, "trailing free slot dropped");
assert_eq!(d.free, vec![0]);
insert(&mut d, "g.rs", 1);
d.insert(entry("g.rs", 1));
assert_eq!((d.file_count, d.files.len()), (2, 2));
assert_consistent(&d);
}
#[test]
@@ -850,65 +312,4 @@ mod tests {
assert_eq!(char_mask("ab") & char_mask("Bad"), char_mask("ab"));
assert_ne!(char_mask("z") & char_mask("abc"), char_mask("z"));
}
fn insert_symbols(d: &mut IndexData, path: &str, symbols: Vec<StoredSymbol>) {
let record = FileRecord { symbols, ..Default::default() };
let (e, s) = FileEntry::new(PathBuf::from("/r").join(path), path.to_owned(), record);
d.insert(e, s);
}
fn symbol(n: &str) -> StoredSymbol {
StoredSymbol { name: n.to_owned(), kind: SymbolKind::Function, container: None, range: Range::default() }
}
#[test]
fn compaction_keeps_entries_and_records_in_sync() {
let mut d = IndexData::default();
insert(&mut d, "a.rs", 3);
insert(&mut d, "b/c.rs", 2);
insert_symbols(&mut d, "a.rs", vec![symbol("fresh")]);
d.shrink();
assert_consistent(&d);
assert_eq!(d.syms.head.len() as u32, d.symbol_count, "compaction drops dead symbol rows");
let names = |d: &IndexData, p: &str| -> Vec<String> {
let e = d.get(Path::new(p)).unwrap();
d.record(e).symbols.into_iter().map(|s| s.name).collect()
};
assert_eq!(names(&d, "/r/a.rs"), ["fresh"]);
assert_eq!(names(&d, "/r/b/c.rs"), ["s0", "s1"]);
assert_eq!(d.symbol_count, 3);
}
#[test]
fn large_replacements_reclaim_symbol_storage() {
let mut d = IndexData::default();
insert_symbols(&mut d, "a.rs", (0..5_000).map(|i| symbol(&format!("old{i}"))).collect());
assert_eq!(d.syms.head.len(), 5_000);
insert_symbols(&mut d, "a.rs", vec![symbol("fresh")]);
assert_eq!(d.syms.head.len(), 1, "dead symbol rows are reclaimed");
assert_eq!((d.symbol_count, d.file_count), (1, 1));
assert_consistent(&d);
}
#[test]
fn tree_removal_leaves_no_hot_rows_or_dirs() {
let mut d = IndexData::default();
insert(&mut d, "a/b/one.rs", 2);
insert(&mut d, "a/b/two.rs", 3);
insert(&mut d, "a/three.rs", 1);
insert(&mut d, "elsewhere/four.rs", 1);
assert_eq!(d.remove_tree(Path::new("/r/a")).len(), 3);
assert_consistent(&d);
assert_eq!(d.hot.rows.iter().filter(|r| r.live).count(), 1);
assert!(d.hot.row(0).is_none(), "removed slots have no live row");
assert_eq!(d.dirs.len(), 1, "elsewhere/ only");
assert_eq!(d.dirs_hot.rows.len(), 1);
assert_eq!(d.symbol_count, 1);
d.shrink();
assert_eq!(d.syms.head.len() as u32, d.symbol_count);
// Slots are reusable and the new row replaces the tombstone.
insert(&mut d, "a/five.rs", 2);
assert_consistent(&d);
assert_eq!(d.file_count, 2);
}
}
+17 -9
View File
@@ -199,11 +199,12 @@ impl ProjectIndex {
return None;
}
Some(
data.symbols_of(file)
file.symbols
.iter()
.map(|s| ide_language::SymbolTag {
name: s.name.to_string(),
kind: s.kind,
container: s.container.map(ToString::to_string),
container: s.container.as_ref().map(ToString::to_string),
range: s.range,
})
.collect(),
@@ -230,7 +231,7 @@ impl ProjectIndex {
pub fn find_files(&self, query: &str, limit: usize) -> Vec<FileMatch> {
let recent = self.shared.recent.lock().clone();
let d = self.shared.data.read();
matching::find_files(&d, &matching::Recents::new(&recent), query, limit)
matching::find_files(&d, &matching::Recents { paths: &recent }, query, limit)
}
/// All indexed files with this exact basename, for resolving printed source locations.
@@ -273,7 +274,7 @@ impl ProjectIndex {
pub fn find_symbols(&self, query: &str, filter: SymbolFilter, limit: usize) -> Vec<SymbolMatch> {
let recent = self.shared.recent.lock().clone();
let d = self.shared.data.read();
matching::find_symbols(&d, &matching::Recents::new(&recent), query, filter, limit)
matching::find_symbols(&d, &matching::Recents { paths: &recent }, query, filter, limit)
}
/// Scoped symbol search with cooperative interruption, including lock waits.
@@ -302,7 +303,15 @@ impl ProjectIndex {
break data;
}
};
matching::find_symbols_until(&data, &matching::Recents::new(&recent), query, filter, limit, include, stopped)
matching::find_symbols_until(
&data,
&matching::Recents { paths: &recent },
query,
filter,
limit,
include,
stopped,
)
}
/// Symbols named exactly `name` (of `kind`, if given): `.card` selectors for `class="card"`.
@@ -514,8 +523,7 @@ pub mod bench_support {
})
.collect();
let record = FileRecord { symbols, ..Default::default() };
let (entry, symbols) = FileEntry::new(PathBuf::from("/bench").join(&rel), rel, record);
data.insert(entry, symbols);
data.insert(FileEntry::new(PathBuf::from("/bench").join(&rel), rel, record));
}
let recent = data.paths().take(MAX_RECENT).cloned().collect();
Self { data, recent }
@@ -530,7 +538,7 @@ pub mod bench_support {
}
pub fn find_files(&self, query: &str, limit: usize) -> Vec<FileMatch> {
matching::find_files(&self.data, &matching::Recents::new(&self.recent), query, limit)
matching::find_files(&self.data, &matching::Recents { paths: &self.recent }, query, limit)
}
pub fn find_dirs(&self, query: &str, limit: usize) -> Vec<FileMatch> {
@@ -538,7 +546,7 @@ pub mod bench_support {
}
pub fn find_symbols(&self, query: &str, filter: SymbolFilter, limit: usize) -> Vec<SymbolMatch> {
matching::find_symbols(&self.data, &matching::Recents::new(&self.recent), query, filter, limit)
matching::find_symbols(&self.data, &matching::Recents { paths: &self.recent }, query, filter, limit)
}
}
}
+108 -160
View File
@@ -4,19 +4,14 @@
//! (bit-mask pre-filter, nucleo score, exact/prefix boosts) and keeps the best
//! few; the survivors then get match indices, CamelHump and recency boosts and
//! are re-sorted.
//!
//! The scan streams the compact tables in `data` (`HotFiles`/`HotDirs`/`SymTable`):
//! only the hot fields per candidate and strings from contiguous arenas, with the
//! thread-local matcher and one UTF-32 buffer per scan rather than per candidate.
use crate::data::{DirRow, FileEntry, FileRow, IndexData, SymTable, char_mask};
use crate::data::{FileEntry, IndexData, char_mask};
use crate::{FileMatch, SymbolFilter, SymbolMatch};
use ide_core::SymbolKind;
use nucleo_matcher::{Config, Matcher, Utf32Str, Utf32String};
use rayon::prelude::*;
use std::cell::RefCell;
use std::cmp::Ordering;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
const TIER_NAME: u32 = 1 << 20;
@@ -59,11 +54,6 @@ impl Needle {
m.fuzzy_match(Utf32Str::new(hay, buf), self.utf32.slice(..)).map(u32::from)
}
/// `hay` is pure ASCII: skip `Utf32Str::new`'s check and UTF-32 conversion.
fn score_ascii(&self, m: &mut Matcher, hay: &str) -> Option<u32> {
m.fuzzy_match(Utf32Str::Ascii(hay.as_bytes()), self.utf32.slice(..)).map(u32::from)
}
fn indices(&self, m: &mut Matcher, hay: &str, buf: &mut Vec<char>, out: &mut Vec<u32>) -> Option<u32> {
out.clear();
let s = m.fuzzy_indices(Utf32Str::new(hay, buf), self.utf32.slice(..), out).map(u32::from);
@@ -115,9 +105,6 @@ struct Cand {
slot: u32,
sym: u32,
score: u32,
/// Candidate name/rel length: the first tie-break key, cached so ranking a pool
/// does not chase it through the tables.
len: u32,
}
fn keep_best(mut v: Vec<Cand>, k: usize, tiebreak: &impl Fn(&Cand, &Cand) -> Ordering) -> Vec<Cand> {
@@ -132,7 +119,7 @@ fn scan(
data: &IndexData,
pool: usize,
tiebreak: &(impl Fn(&Cand, &Cand) -> Ordering + Sync),
per_file: impl Fn(u32, &FileRow, &mut Matcher, &mut Vec<Cand>) + Sync,
per_file: impl Fn(u32, &FileEntry, &mut Vec<Cand>) + Sync,
) -> Vec<Cand> {
scan_until(data, pool, tiebreak, &|| false, per_file).unwrap_or_default()
}
@@ -142,22 +129,18 @@ fn scan_until(
pool: usize,
tiebreak: &(impl Fn(&Cand, &Cand) -> Ordering + Sync),
stopped: &(impl Fn() -> bool + Sync),
per_file: impl Fn(u32, &FileRow, &mut Matcher, &mut Vec<Cand>) + Sync,
per_file: impl Fn(u32, &FileEntry, &mut Vec<Cand>) + Sync,
) -> Option<Vec<Cand>> {
// One row per slot, so this is exactly the old scan over `files`: same item
// order and count, and free slots are skipped exactly like `None` was, keeping
// the pruning boundaries (and therefore the candidate pool) unchanged.
data.hot
.rows
data.files
.par_iter()
.enumerate()
.with_min_len(256)
.try_fold(Vec::new, |mut acc, (slot, row)| {
.try_fold(Vec::new, |mut acc, (slot, e)| {
if stopped() {
return None;
}
if row.live {
with_matcher(|m| per_file(slot as u32, row, m, &mut acc));
if let Some(e) = e {
per_file(slot as u32, e, &mut acc);
if acc.len() >= pool * 2 {
acc = keep_best(acc, pool, tiebreak);
}
@@ -172,22 +155,15 @@ fn scan_until(
pub(crate) struct Recents<'a> {
pub paths: &'a [PathBuf],
/// Position of each path, so ranking a survivor does not scan the list.
/// Duplicates keep their first position, like `position`.
ranks: HashMap<&'a Path, usize>,
}
impl<'a> Recents<'a> {
pub(crate) fn new(paths: &'a [PathBuf]) -> Self {
let mut ranks = HashMap::with_capacity(paths.len());
for (i, p) in paths.iter().enumerate() {
ranks.entry(p.as_path()).or_insert(i);
}
Self { paths, ranks }
impl Recents<'_> {
fn rank(&self, path: &std::path::Path) -> Option<usize> {
self.paths.iter().position(|p| p == path)
}
fn bonus(&self, path: &Path) -> u32 {
self.ranks.get(path).map_or(0, |&r| RECENT_MAX.saturating_sub(r as u32 * 12))
fn bonus(&self, path: &std::path::Path) -> u32 {
self.rank(path).map_or(0, |r| RECENT_MAX.saturating_sub(r as u32 * 12))
}
}
@@ -233,31 +209,29 @@ pub(crate) fn find_files(data: &IndexData, recents: &Recents, query: &str, limit
Some((dir, name)) => FileQuery::Path { dir: Needle::new(dir), name: Needle::new(name) },
None => FileQuery::Name(Needle::new(&q)),
};
let tiebreak = |a: &Cand, b: &Cand| a.len.cmp(&b.len).then_with(|| data.hot.rel(a.slot).cmp(data.hot.rel(b.slot)));
let tiebreak = |a: &Cand, b: &Cand| {
let (ea, eb) = (file(data, a.slot), file(data, b.slot));
ea.rel.len().cmp(&eb.rel.len()).then_with(|| ea.rel.cmp(&eb.rel))
};
let pool = limit * 4 + 64;
let mut cands = scan(data, pool, &tiebreak, |slot, row, m, acc| {
let mut cands = scan(data, pool, &tiebreak, |slot, e, acc| {
let mut buf = Vec::new();
if let Some(score) = base_file_score(m, &fq, row, data.hot.text(), &mut buf) {
acc.push(Cand { slot, sym: u32::MAX, score, len: row.rel.len });
if let Some(score) = with_matcher(|m| base_file_score(m, &fq, e, &mut buf)) {
acc.push(Cand { slot, sym: u32::MAX, score });
}
});
// Recent files always compete for the final ranking.
with_matcher(|m| {
let mut buf = Vec::new();
for p in recents.paths {
if data.get(p).is_none() {
continue;
}
for p in recents.paths {
if let Some(e) = data.get(p) {
let Some(slot) = data.slot(p) else { continue };
if cands.iter().any(|c| c.slot == slot) {
continue;
}
let Some(row) = data.hot.row_of_slot(slot) else { continue };
if let Some(score) = base_file_score(m, &fq, row, data.hot.text(), &mut buf) {
cands.push(Cand { slot, sym: u32::MAX, score, len: row.rel.len });
if !cands.iter().any(|c| c.slot == slot) {
let mut buf = Vec::new();
if let Some(score) = with_matcher(|m| base_file_score(m, &fq, e, &mut buf)) {
cands.push(Cand { slot, sym: u32::MAX, score });
}
}
}
});
}
let mut out: Vec<(u32, &FileEntry, Vec<u32>)> = with_matcher(|m| {
let mut buf = Vec::new();
cands
@@ -299,46 +273,30 @@ pub(crate) fn find_dirs(data: &IndexData, query: &str, limit: usize) -> Vec<File
}
let by_path = q.contains('/');
let needle = Needle::new(q.trim_end_matches('/'));
let text = data.dirs_hot.text();
// (score, rel without the trailing `/`, char offset of the name when it matched by name)
let (mut best, _): (Vec<_>, Vec<char>) = data
.dirs_hot
.rows
let score = |m: &mut Matcher, buf: &mut Vec<char>, rel: &str| -> Option<(u32, Option<u32>)> {
let trimmed = &rel[..rel.len() - 1];
let start = trimmed.rfind('/').map_or(0, |i| i + 1);
let name = &trimmed[start..];
if !by_path
&& needle.mask & char_mask(name) == needle.mask
&& let Some(s) = needle.score(m, name, buf)
{
return Some((TIER_NAME + s * 4 + needle.name_boost(name), Some(trimmed[..start].chars().count() as u32)));
}
if needle.mask & char_mask(trimmed) != needle.mask {
return None;
}
needle.score(m, trimmed, buf).map(|s| (s, None))
};
let mut best: Vec<(u32, &str, &Path, Option<u32>)> = data
.dirs
.par_iter()
.fold(
|| (Vec::new(), Vec::new()),
|(mut found, mut buf), row: &DirRow| {
let rel = row.rel(text);
let trimmed = &rel[..rel.len() - 1];
let name = &trimmed[row.name_start as usize..];
with_matcher(|m| {
let hit = if !by_path
&& needle.mask & row.name_mask == needle.mask
&& let Some(s) = needle.score(m, name, &mut buf)
{
Some((
TIER_NAME + s * 4 + needle.name_boost(name),
Some(trimmed[..row.name_start as usize].chars().count() as u32),
))
} else if needle.mask & row.rel_mask != needle.mask {
None
} else {
needle.score(m, trimmed, &mut buf).map(|s| (s, None))
};
if let Some((s, at)) = hit {
found.push((s, rel, row.path.as_path(), at));
}
});
(found, buf)
},
)
.reduce(
|| (Vec::new(), Vec::new()),
|mut a, b| {
a.0.extend(b.0);
a
},
);
.filter_map(|(rel, (path, _))| {
let mut buf = Vec::new();
with_matcher(|m| score(m, &mut buf, rel)).map(|(s, at)| (s, &**rel, path.as_path(), at))
})
.collect();
let order = |a: &(u32, &str, &Path, Option<u32>), b: &(u32, &str, &Path, Option<u32>)| {
b.0.cmp(&a.0).then_with(|| a.1.len().cmp(&b.1.len())).then_with(|| a.1.cmp(b.1))
};
@@ -380,39 +338,33 @@ fn file(data: &IndexData, slot: u32) -> &FileEntry {
data.files[slot as usize].as_ref().expect("candidate slots are live")
}
fn base_file_score(m: &mut Matcher, q: &FileQuery, row: &FileRow, text: &str, buf: &mut Vec<char>) -> Option<u32> {
// Strings are sliced only once their mask passes, so a rejected row costs two masks.
fn base_file_score(m: &mut Matcher, q: &FileQuery, e: &FileEntry, buf: &mut Vec<char>) -> Option<u32> {
match q {
FileQuery::Name(n) => {
if n.mask & row.name_mask == n.mask {
let name = row.name(text);
let s = if row.ascii { n.score_ascii(m, name) } else { n.score(m, name, buf) };
if let Some(s) = s {
return Some(TIER_NAME + s * 4 + n.name_boost(name));
}
if n.mask & e.name_mask == n.mask
&& let Some(s) = n.score(m, e.name(), buf)
{
return Some(TIER_NAME + s * 4 + n.name_boost(e.name()));
}
if n.mask & row.rel_mask == n.mask {
let rel = row.rel(text);
return if row.ascii { n.score_ascii(m, rel) } else { n.score(m, rel, buf) };
if n.mask & e.rel_mask == n.mask {
return n.score(m, &e.rel, buf);
}
None
}
FileQuery::Path { dir, name: n } => {
let name_score = if n.is_empty() {
FileQuery::Path { dir, name } => {
let name_score = if name.is_empty() {
0
} else {
if n.mask & row.name_mask != n.mask {
if name.mask & e.name_mask != name.mask {
return None;
}
let name = row.name(text);
let s = if row.ascii { n.score_ascii(m, name) } else { n.score(m, name, buf) }?;
s * 4 + n.name_boost(name)
name.score(m, e.name(), buf)? * 4 + name.name_boost(e.name())
};
if dir.mask & row.dir_mask != dir.mask {
let dir_str = e.dir();
if dir.mask & char_mask(dir_str) != dir.mask {
return None;
}
let dir_str = row.dir(text);
let dir_score = if row.ascii { dir.score_ascii(m, dir_str) } else { dir.score(m, dir_str, buf) }?;
let dir_score = dir.score(m, dir_str, buf)?;
Some(TIER_NAME + name_score + dir_score)
}
}
@@ -444,21 +396,22 @@ fn symbol_score(
m: &mut Matcher,
name: &Needle,
container: Option<&Needle>,
syms: &SymTable,
i: u32,
s: &crate::data::Sym,
buf: &mut Vec<char>,
) -> Option<u32> {
let symbol = syms.name(i);
let mut score = if name.is_empty() { 0 } else { name.score(m, symbol, buf)? * 4 };
score += name.name_boost(symbol);
if name.mask & s.mask != name.mask {
return None;
}
let mut score = if name.is_empty() { 0 } else { name.score(m, &s.name, buf)? * 4 };
score += name.name_boost(&s.name);
if let Some(cq) = container {
let c = syms.container(i)?;
let c = s.container.as_deref()?;
if cq.mask & char_mask(c) != cq.mask {
return None;
}
score += cq.score(m, c, buf)? + cq.name_boost(c) / 4;
}
if syms.kind(i).is_type_like() {
if s.kind.is_type_like() {
score += TYPE_LIKE;
}
Some(score)
@@ -502,35 +455,30 @@ pub(crate) fn find_symbols_until(
let (container_q, name_q) = split_symbol_query(&q);
let name = Needle::new(name_q);
let container = container_q.map(Needle::new);
let syms = &data.syms;
let tiebreak = |a: &Cand, b: &Cand| a.len.cmp(&b.len).then_with(|| data.hot.rel(a.slot).cmp(data.hot.rel(b.slot)));
let sym = |c: &Cand| &file(data, c.slot).symbols[c.sym as usize];
let tiebreak = |a: &Cand, b: &Cand| {
let (sa, sb) = (sym(a), sym(b));
sa.name.len().cmp(&sb.name.len()).then_with(|| file(data, a.slot).rel.cmp(&file(data, b.slot).rel))
};
let pool = limit * 4 + 64;
let cands = scan_until(data, pool, &tiebreak, stopped, |slot, row, m, acc| {
// Whole-file pre-filter: when the union of the file's symbol masks
// already misses a query character, no symbol can match.
if row.sym_len == 0 || name.mask & row.sym_mask != name.mask {
let cands = scan_until(data, pool, &tiebreak, stopped, |slot, e, acc| {
if e.symbols.is_empty() || !include(&e.path) {
return;
}
let e = file(data, slot);
if !include(&e.path) {
return;
}
let mut buf = Vec::new();
for (k, head) in syms.heads(row.sym_start, row.sym_len).iter().enumerate() {
if stopped() {
break;
with_matcher(|m| {
let mut buf = Vec::new();
for (i, s) in e.symbols.iter().enumerate() {
if stopped() {
break;
}
if filter == SymbolFilter::Types && !s.kind.is_type_like() {
continue;
}
if let Some(score) = symbol_score(m, &name, container.as_ref(), s, &mut buf) {
acc.push(Cand { slot, sym: i as u32, score });
}
}
if filter == SymbolFilter::Types && !syms.kind(row.sym_start + k as u32).is_type_like() {
continue;
}
if name.mask & head.mask != name.mask {
continue;
}
let i = row.sym_start + k as u32;
if let Some(score) = symbol_score(m, &name, container.as_ref(), syms, i, &mut buf) {
acc.push(Cand { slot, sym: i, score, len: head.name.len });
}
}
});
})?;
if stopped() {
return None;
@@ -541,32 +489,33 @@ pub(crate) fn find_symbols_until(
.into_iter()
.take_while(|_| !stopped())
.filter_map(|c| {
let symbol = syms.name(c.sym);
let s = sym(&c);
let e = file(data, c.slot);
let mut idx = Vec::new();
if !name.is_empty() {
name.indices(m, symbol, &mut buf, &mut idx)?;
name.indices(m, &s.name, &mut buf, &mut idx)?;
}
let bonus = hump_bonus(symbol, &idx) + recents.bonus(&e.path) / 4;
let bonus = hump_bonus(&s.name, &idx) + recents.bonus(&e.path) / 4;
Some((c.score + bonus, c, idx))
})
.collect()
});
out.sort_by(|a, b| {
b.0.cmp(&a.0).then_with(|| tiebreak(&a.1, &b.1)).then_with(|| syms.range(a.1.sym).cmp(&syms.range(b.1.sym)))
b.0.cmp(&a.0).then_with(|| tiebreak(&a.1, &b.1)).then_with(|| sym(&a.1).range.cmp(&sym(&b.1).range))
});
out.truncate(limit);
let results = out
.into_iter()
.map(|(score, c, indices)| {
let e = file(data, c.slot);
let s = &e.symbols[c.sym as usize];
SymbolMatch {
name: syms.name(c.sym).to_string(),
kind: syms.kind(c.sym),
container: syms.container(c.sym).map(str::to_owned),
name: s.name.to_string(),
kind: s.kind,
container: s.container.as_deref().map(str::to_owned),
path: e.path.clone().into(),
rel_path: e.rel.to_string(),
range: syms.range(c.sym),
range: s.range,
score,
indices,
}
@@ -592,25 +541,24 @@ pub(crate) fn symbols_named_within(
) -> Vec<SymbolMatch> {
let mask = char_mask(name);
let within = |e: &FileEntry| roots.is_empty() || roots.iter().any(|root| e.path.starts_with(root));
let syms = &data.syms;
let mut out: Vec<SymbolMatch> = data
.files
.iter()
.flatten()
.filter(|e| within(e))
.flat_map(|e| {
(e.sym_start..e.sym_start + e.sym_len)
.filter(|&i| syms.mask(i) == mask && syms.name(i) == name && kind.is_none_or(|k| syms.kind(i) == k))
.map(move |i| SymbolMatch {
name: syms.name(i).to_string(),
kind: syms.kind(i),
container: syms.container(i).map(str::to_owned),
e.symbols.iter().filter(|s| s.mask == mask && &*s.name == name && kind.is_none_or(|k| s.kind == k)).map(
move |s| SymbolMatch {
name: s.name.to_string(),
kind: s.kind,
container: s.container.as_deref().map(str::to_owned),
path: e.path.clone().into(),
rel_path: e.rel.to_string(),
range: syms.range(i),
range: s.range,
score: 0,
indices: Vec::new(),
})
},
)
})
.collect();
out.sort_by(|a, b| a.rel_path.cmp(&b.rel_path).then_with(|| a.range.cmp(&b.range)));
+8 -7
View File
@@ -173,7 +173,7 @@ impl Shared {
let root = filter.root().to_owned();
match store.load() {
Ok(records) => {
let entries: Vec<(FileEntry, Vec<StoredSymbol>)> = records
let entries: Vec<FileEntry> = records
.into_par_iter()
.filter(|(rel, _)| {
let path = root.join(rel);
@@ -182,8 +182,8 @@ impl Shared {
.map(|(rel, rec)| FileEntry::new(root.join(&rel), rel, rec))
.collect();
let mut d = self.data.write();
for (e, symbols) in entries {
d.insert(e, symbols);
for e in entries {
d.insert(e);
}
}
Err(e) => tracing::warn!("index cache load failed: {e}"),
@@ -321,7 +321,8 @@ impl Shared {
match self.extract(f, prev_hash) {
Extracted::Unchanged => {
// Same content, new mtime: keep the symbols, refresh metadata.
let mut rec = self.data.read().record_of(&f.path)?;
let d = self.data.read();
let mut rec = d.get(&f.path)?.record();
rec.mtime = f.mtime;
rec.size = f.size;
rec.ignored = f.ignored;
@@ -337,11 +338,11 @@ impl Shared {
let puts: Vec<(String, FileRecord)> =
extracted.iter().filter_map(|(_, rec, path)| Some((filter.store_key(path)?, rec.clone()))).collect();
{
let entries: Vec<(FileEntry, Vec<StoredSymbol>)> =
let entries: Vec<FileEntry> =
extracted.into_iter().map(|(rel, rec, path)| FileEntry::new(path, rel, rec)).collect();
let mut d = self.data.write();
for (e, symbols) in entries {
d.insert(e, symbols);
for e in entries {
d.insert(e);
}
}
self.persist(&puts, &[], false);
+4 -10
View File
@@ -223,10 +223,6 @@ pub trait Handler: Send + Sync + 'static {
/// Called once the response to a `method` request is queued: what it sends follows the response.
fn after_response(&self, _method: &str) {}
/// Adjusts every message from the peer (responses included) before it is dispatched, e.g.
/// to repair values the peer spells more loosely than the types that read them accept.
fn normalize_incoming(&self, _message: &mut Value) {}
}
/// A [`Handler`] that ignores notifications and rejects requests.
@@ -589,10 +585,9 @@ impl Peer {
.expect("spawn jsonrpc notification thread");
}
// Reader: parses and dispatches; runs no handler code beyond `normalize_incoming`.
// Reader: parses and dispatches; never runs handler code itself.
{
let inner = inner.clone();
let handler = handler.clone();
tokio::spawn(async move {
let mut reader = BufReader::new(reader);
let seen_message = AtomicBool::new(false);
@@ -615,7 +610,7 @@ impl Peer {
match msg {
Ok(Some(body)) => {
seen_message.store(true, Ordering::Relaxed);
dispatch(&inner, &*handler, &notif_tx, &body);
dispatch(&inner, &notif_tx, &body);
}
Ok(None) => break,
Err(e) => {
@@ -788,16 +783,15 @@ async fn handle_request(inner: Arc<Inner>, handler: Arc<dyn Handler>, id: Value,
}
}
fn dispatch(inner: &Arc<Inner>, handler: &dyn Handler, notif_tx: &std::sync::mpsc::Sender<Incoming>, body: &[u8]) {
fn dispatch(inner: &Arc<Inner>, notif_tx: &std::sync::mpsc::Sender<Incoming>, body: &[u8]) {
tracing::trace!(target: "ide_jsonrpc::wire", "<-- {}", String::from_utf8_lossy(body));
let mut msg: Value = match serde_json::from_slice(body) {
let msg: Value = match serde_json::from_slice(body) {
Ok(v) => v,
Err(e) => {
tracing::warn!("jsonrpc: unparsable message: {e}");
return;
}
};
handler.normalize_incoming(&mut msg);
let Value::Object(mut obj) = msg else {
tracing::warn!("jsonrpc: ignoring non-object message");
return;
-26
View File
@@ -541,29 +541,3 @@ async fn output_before_the_handshake_is_reported_not_dropped() {
assert!(lines.iter().any(|l| l == "python: not found"), "{lines:?}");
assert!(lines.iter().any(|l| l.ends_with(". . .") && l.len() > 6), "{lines:?}");
}
#[tokio::test(flavor = "multi_thread")]
async fn incoming_messages_are_normalized_before_dispatch() {
struct Server;
impl Handler for Server {
fn on_notification(&self, _: &str, _: Value) {}
fn on_request(&self, _: &str, params: Value) -> Result<Value, (i64, String)> {
Ok(params)
}
}
struct Client;
impl Handler for Client {
fn on_notification(&self, _: &str, _: Value) {}
fn on_request(&self, _: &str, _: Value) -> Result<Value, (i64, String)> {
Err((codes::METHOD_NOT_FOUND, "no".into()))
}
fn normalize_incoming(&self, message: &mut Value) {
if let Some(result) = message.get_mut("result") {
*result = json!("normalized");
}
}
}
let (client, _server) = pair(Arc::new(Client), Arc::new(Server));
let v: Value = client.request("echo", "raw", None).await.unwrap();
assert_eq!(v, json!("normalized"));
}
-2
View File
@@ -79,7 +79,6 @@ const BUILTIN: &[(&str, &str)] = &[
("scala", include_str!("../../../languages/scala.toml")),
("scss", include_str!("../../../languages/scss.toml")),
("shellscript", include_str!("../../../languages/shellscript.toml")),
("slint", include_str!("../../../languages/slint.toml")),
("sql", include_str!("../../../languages/sql.toml")),
("svelte", include_str!("../../../languages/svelte.toml")),
("swift", include_str!("../../../languages/swift.toml")),
@@ -1047,7 +1046,6 @@ mod tests {
("icon.svg.import", "godot-resource"),
("export_presets.cfg", "godot-resource"),
("water.gdshader", "gdshader"),
("app-window.slint", "slint"),
("notes.txt", "plaintext"),
];
for (path, id) in cases {
-4
View File
@@ -391,10 +391,6 @@ fn tsserver_ran_out_of_memory(text: &str) -> bool {
}
impl Handler for ClientHandler {
fn normalize_incoming(&self, message: &mut Value) {
crate::uri::encode_loose_file_uris(message);
}
fn on_notification(&self, method: &str, params: Value) {
let Some(mgr) = self.mgr.upgrade() else { return };
match method {
+2 -41
View File
@@ -445,12 +445,8 @@ impl Server {
message.push_str(":\n");
message.push_str(&tail);
}
// Starting the same build again can't help: only an update can.
let expired = self.reported_expired_build();
let delay = if expired { None } else { self.record_crash() };
message.push_str(if expired {
"\nThis build has expired; update it to start it again."
} else if delay.is_some() {
let delay = self.record_crash();
message.push_str(if delay.is_some() {
"\nRestarting…"
} else {
"\nCrashed repeatedly; restart it manually."
@@ -761,12 +757,6 @@ impl Server {
crash_excerpt(&lines, n)
}
/// JetBrains preview builds (kotlin-lsp) refuse to run past their expiry date and
/// exit with "This build of kotlin-server has expired".
fn reported_expired_build(&self) -> bool {
self.stderr.lock().iter().any(|l| is_expired_build_line(l))
}
pub fn take_progress(&self) -> Vec<(TaskId, String)> {
*self.loading.lock() = None;
self.external_loading.store(0, std::sync::atomic::Ordering::Release);
@@ -921,10 +911,6 @@ fn is_error_headline(line: &str) -> bool {
named || l.starts_with("Exception in thread") || l.starts_with("thread '") && l.contains("panicked")
}
fn is_expired_build_line(line: &str) -> bool {
line.contains("This build of ") && line.contains(" has expired")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1085,31 +1071,6 @@ mod tests {
assert_eq!(waiter.await.unwrap(), (true, ServerStatus::Crashed));
}
#[test]
fn an_expired_build_is_not_restarted() {
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
let s = server();
let generation = s.begin_start().unwrap();
s.push_stderr(generation, "idea.system.path=/tmp/idea-system".into());
s.push_stderr(
generation,
"This build of kotlin-server has expired. The IDE will now close. Please download a new build".into(),
);
assert_eq!(s.on_closed(generation, &sink, Some(7)), Some(None));
assert_eq!(s.status(), ServerStatus::Crashed);
assert!(s.status_message().unwrap().ends_with("This build has expired; update it to start it again."));
assert!(matches!(s.state.lock().conn, Conn::Failed));
// A manual restart (after an update) starts it again.
s.reset_for_restart();
assert!(s.begin_start().is_some());
// Any other crash is restarted.
let s = server();
let generation = s.begin_start().unwrap();
s.push_stderr(generation, "Exception in thread main".into());
assert_eq!(s.on_closed(generation, &sink, Some(1)), Some(Some(Duration::from_millis(500))));
}
#[test]
fn a_superseded_start_is_not_announced() {
let s = server();
-71
View File
@@ -1,8 +1,6 @@
//! `file://` URI ⇄ path conversion (RFC 8089, VS Code-compatible encoding).
use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode};
use serde_json::Value;
use std::borrow::Cow;
use std::path::{Path, PathBuf};
use std::str::FromStr;
@@ -24,53 +22,6 @@ const PATH: &AsciiSet = &CONTROLS
.add(b'|')
.add(b'}');
/// What RFC 3986 doesn't allow in a URI and a server may still leave unencoded in a `file:`
/// URI (non-ASCII is always encoded); `%` stays, it starts the escapes already there.
const LOOSE: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'<')
.add(b'>')
.add(b'[')
.add(b'\\')
.add(b']')
.add(b'^')
.add(b'`')
.add(b'{')
.add(b'|')
.add(b'}');
/// Percent-encodes the characters URIs don't allow in every `file:` URI in `value`, string
/// values and object keys alike (a workspace edit's `changes`). Eclipse's jdtls sends a path
/// as is (`file:///H:/项目/…`), which `lsp_types::Uri` rejects, failing the whole response.
pub fn encode_loose_file_uris(value: &mut Value) {
match value {
Value::String(s) => {
if let Some(encoded) = encode_loose(s) {
*s = encoded;
}
}
Value::Array(items) => items.iter_mut().for_each(encode_loose_file_uris),
Value::Object(map) => {
if map.keys().any(|k| encode_loose(k).is_some()) {
*map = std::mem::take(map).into_iter().map(|(k, v)| (encode_loose(&k).unwrap_or(k), v)).collect();
}
map.values_mut().for_each(encode_loose_file_uris);
}
_ => {}
}
}
fn encode_loose(s: &str) -> Option<String> {
if !s.get(..5).is_some_and(|scheme| scheme.eq_ignore_ascii_case("file:")) {
return None;
}
match Cow::from(utf8_percent_encode(s, LOOSE)) {
Cow::Owned(encoded) => Some(encoded),
Cow::Borrowed(_) => None,
}
}
pub fn path_to_uri_string(path: &Path) -> String {
let s = if cfg!(windows) {
dunce::simplified(path).to_string_lossy().replace('\\', "/")
@@ -144,28 +95,6 @@ pub use ide_core::text::is_virtual_uri;
mod tests {
use super::*;
#[test]
fn file_uris_a_server_leaves_unencoded_are_encoded() {
// jdtls on a project under a Chinese folder (`java.usages` / references response).
let mut v = serde_json::json!({ "result": [{
"location": { "uri": "file:///H:/项目/online disk/A.java", "range": { "start": { "line": 1, "character": 2 }, "end": { "line": 1, "character": 3 } } },
"kind": "unknown"
}], "edit": { "changes": { "file:///tmp/目录/b.java": [] } }, "text": "项目" });
let raw = v["result"][0]["location"].clone();
assert!(serde_json::from_value::<lsp_types::Location>(raw).is_err(), "lsp-types rejects it as sent");
encode_loose_file_uris(&mut v);
let uri = v["result"][0]["location"]["uri"].as_str().unwrap();
assert_eq!(uri, "file:///H:/%E9%A1%B9%E7%9B%AE/online%20disk/A.java");
let location: lsp_types::Location = serde_json::from_value(v["result"][0]["location"].clone()).unwrap();
assert!(uri_to_path(location.uri.as_str()).unwrap().ends_with("项目/online disk/A.java"));
assert!(v["edit"]["changes"].get("file:///tmp/%E7%9B%AE%E5%BD%95/b.java").is_some());
assert_eq!(v["text"], "项目", "only file: URIs change");
// Already encoded: untouched.
let mut encoded = serde_json::json!("file:///a%20b/%E9%A1%B9.java");
encode_loose_file_uris(&mut encoded);
assert_eq!(encoded, "file:///a%20b/%E9%A1%B9.java");
}
#[cfg(unix)]
#[test]
fn round_trip() {
+1 -4
View File
@@ -55,7 +55,7 @@ use views_bindings::exports::openidea::plugin::view_provider as wit_view;
use views_bindings::openidea::plugin::view_types as wit_vt;
/// The API version of the world above, `major.minor` of [`host_api`].
pub const API_VERSION: &str = "1.8";
pub const API_VERSION: &str = "1.7";
const EPOCH_TICK: Duration = Duration::from_millis(10);
pub(crate) const MAX_TEXT: usize = 1 << 20;
pub(crate) const MAX_TITLE: usize = 256;
@@ -1058,9 +1058,6 @@ fn event_to_wit(e: WorkbenchEvent) -> wit_events::Event {
}),
WorkbenchEvent::EditorFocused(p) => wit_events::Event::EditorFocused(p),
WorkbenchEvent::VcsChanged(p) => wit_events::Event::VcsChanged(p),
WorkbenchEvent::ProblemsChanged => {
wit_events::Event::Other(wit_types::Extension { kind: "problems-changed".into(), data: "{}".into() })
}
WorkbenchEvent::Scheduled => wit_events::Event::Scheduled,
WorkbenchEvent::ActionPerformed(a) => wit_events::Event::ActionPerformed(wit_events::ActionPerformed {
action: a.action,
+1 -8
View File
@@ -119,13 +119,11 @@ pub enum EventKind {
EditorFocused,
ActionPerformed,
VcsChanged,
/// Problems of the window's files changed (a language server or a report).
ProblemsChanged,
Scheduled,
}
impl EventKind {
pub const ALL: [Self; 17] = [
pub const ALL: [Self; 16] = [
Self::DocumentChanged,
Self::WebsocketReady,
Self::FileClosed,
@@ -141,7 +139,6 @@ impl EventKind {
Self::EditorFocused,
Self::ActionPerformed,
Self::VcsChanged,
Self::ProblemsChanged,
Self::Scheduled,
];
@@ -162,7 +159,6 @@ impl EventKind {
Self::EditorFocused => "editor-focused",
Self::ActionPerformed => "action-performed",
Self::VcsChanged => "vcs-changed",
Self::ProblemsChanged => "problems-changed",
Self::Scheduled => "scheduled",
}
}
@@ -298,8 +294,6 @@ pub enum WorkbenchEvent {
EditorFocused(Option<String>),
ActionPerformed(ActionPerformed),
VcsChanged(String),
/// Problems of the window's files changed; plugins re-read `problems`.
ProblemsChanged,
Scheduled,
}
@@ -321,7 +315,6 @@ impl WorkbenchEvent {
Self::EditorFocused(_) => EventKind::EditorFocused,
Self::ActionPerformed(_) => EventKind::ActionPerformed,
Self::VcsChanged(_) => EventKind::VcsChanged,
Self::ProblemsChanged => EventKind::ProblemsChanged,
Self::Scheduled => EventKind::Scheduled,
}
}
+5 -5
View File
@@ -91,15 +91,15 @@ fn the_sdk_carries_the_hosts_wit() {
#[test]
fn the_version_check_runs_compatible_components_and_explains_the_rest() {
let host = v("1.8.0");
for ok in ["1.0.0", "1.3.0", "1.4.7", "1.5.0", "1.6.0", "1.7.0", "1.8.0", "1.0.0-rc.1"] {
let host = v("1.7.0");
for ok in ["1.0.0", "1.3.0", "1.4.7", "1.5.0", "1.6.0", "1.7.0", "1.0.0-rc.1"] {
assert_eq!(check_api(&v(ok), &host), Ok(()), "{ok}");
}
let newer = check_api(&v("1.9.0"), &host).unwrap_err();
assert!(newer.contains("1.9") && newer.contains("update OpenIDEA"), "{newer}");
let newer = check_api(&v("1.8.0"), &host).unwrap_err();
assert!(newer.contains("1.8") && newer.contains("update OpenIDEA"), "{newer}");
assert!(check_api(&v("2.0.0"), &host).unwrap_err().contains("update OpenIDEA"));
let older = check_api(&v("0.2.0"), &host).unwrap_err();
assert!(older.contains("rebuild it with the 1.8 SDK"), "{older}");
assert!(older.contains("rebuild it with the 1.7 SDK"), "{older}");
// 0.x: every minor is its own API.
assert_eq!(check_api(&v("0.2.0"), &v("0.2.5")), Ok(()));
assert!(check_api(&v("0.1.0"), &v("0.2.0")).is_err());
Binary file not shown.
@@ -1 +1 @@
6c50332c868a890b91a7f92ef7f92e76a4818b7435291e99c77c0b9f0931b81c
d6c855f1f91aeb02eec5d6c8a07be4eb64bae0704619f44cb67f3b832daf01ee
Binary file not shown.
@@ -1 +1 @@
fdcb208365db90bcd06d0d0df3d436b69d1229c40f7c63dd6f1ab768e0d236b8
51d00702c42c7167083caea70cf2df022e512bcaf89af8addf526421ff45a15f
Binary file not shown.
@@ -1 +1 @@
3108abe8d381b224ec4b44ecf4c43c045671bd5e221ee21582e25497f8c14dff
40d2fd76ba96b0c93ea0a034c6749709ddc00fc119046e653a50b180a9c4718d
Binary file not shown.
@@ -1 +1 @@
876a84b7da5a4b27fce47cf71a4dc28eb1ea6c9d54283c1b73a2ae2411653bf5
08822287f3649412340f9dbbe2f0ea5a5cd2f543a3c242c5d0a29c422c4c5367
Binary file not shown.
@@ -1 +1 @@
234dc693e01e484b91fa7f2d0ac0e7757fb69c9fba81f06bf8ef1cd3624228d8
43eba23cc14cf3c74bfc934099cd58df20f07ee5df96cc10100b90016c544a7a
-25
View File
@@ -21,31 +21,6 @@ impl Plugin for Fixture {
&[Diagnostic::new(Range::default(), Severity::Info, "Manual problem")],
)
.map(|()| None),
// The host-owned snapshot, one entry per producer: what `problems` reads.
"problems" => {
let files: Vec<String> = host::problems(None)?
.into_iter()
.map(|f| format!("{}:{}:{}", f.path, f.source, f.diagnostics.len()))
.collect();
Ok(Some(files.join("|")))
}
// A line-end annotation in the severity styles the editor draws: the host must
// keep `typo` and `weak` instead of dropping them as unknown.
"decorate" => {
let path = arg.unwrap_or_default();
let item = |style: &str, after: &str| openidea_plugin::Decoration {
range: Range::default(),
style: style.into(),
hover: None,
after: Some(after.into()),
};
host::set_decorations(
path,
"fixture",
&[item("typo", "typo message"), item("weak", "weak message")],
)
.map(|()| None)
}
_ => Err(Error::NotFound(id.into())),
}
}
Binary file not shown.
@@ -1 +1 @@
927c329fd21d12fd5f423b937e1be756be45a4c6dceee08679e762d5d9f511f2
520d9a95328dc772bbb913a2e5e22af8243c95ca45744cbb65b25747653d2a34
Binary file not shown.
@@ -1 +1 @@
e7c77fde915e0765f91929dba2aa8eff606f2302e078b7305d272a4de5b9d80b
acd3d0ee3f886c6a271ce3139200ce5bdb46e40f9c68e495c5ff9b4db98a88ac
Binary file not shown.
@@ -1 +1 @@
3feb9df8b408dedbc3586864db00b92c8cb244c8ad5b8f10c95edb8eb3d30df1
0cf3bc2197f0bd5339fe3c2199bd973e68b3637b8c5ae07902e62659160275e3
Binary file not shown.
@@ -1 +1 @@
3bfce29f90b9d2f20082b9bdd0957c6c58c265a346305ec948f514537a4579c8
17f837c8d6f58800728e00060afe68b2a093085d816d809c9e67065fdd165352
Binary file not shown.
@@ -1 +1 @@
ca72c5cfa2f76a93bc07495d50e58181a9c6c2fc75854cf548bb0278814ddb87
2cca80220fab6764a8e1f51db8ed2f55b603000c8d0dbf5913a441876b7857ba
Binary file not shown.
@@ -1 +1 @@
8a4a7df8fd6cf397750ac24cedf06174ed50e4214ef44029e29f7d0557d426ba
54211437002ad83a9d3d642f2dca5f37cadd89d7d77ddc7de545db87b99fb8b7
Binary file not shown.
@@ -1 +1 @@
8d12313675e0869e087ac4c79bdfb1a9738867e28af2437eff27cb07254f572b
4326d9cdefe71f4bb9aab043ccef883abbd13145b1579352b97c66889f514c68
Binary file not shown.
@@ -1 +1 @@
1b2ad52d4cbb4abb662be5772f050844836f8e3f0c525edd0de19ff45e95a148
2469023f4f29b9abdaa27c9d96774229c4dd002366421dc508926c3fed1fba1b
Binary file not shown.
@@ -1 +1 @@
13babf73d8d3d029aba26641b01a4add3abb3dab84394d8bd676edb064dc0c19
233461ab52a12e27fa600e108e83cab1b930b3e61812e0c048c99c1e18c6d646
Binary file not shown.
@@ -1 +1 @@
a7790bf28783d57ed47f635a58981b80ddaecd7074cf88c539465b57132a9f8f
bfc654523da608bbc008ea6a15a0cfd0b129ffab70088bf6b610786b63b5352e
Binary file not shown.
@@ -1 +1 @@
af7070f6ca3a14cf480260aa21b7a6e95038996d224d40e53a487c96a8f3acf2
c8536be072d27aa89f5e92b5b96e38ed65b7f06c35c05ecbd4a151218203c5f7
@@ -1 +1 @@
16930ecfe74d096e6dcaa5fbf35664128b4d3844f47993a947aabe9d2a9650de
7c0c1f5035e12c794d48cb75a8fc259d11dd99ba3188030fe1d9956720ee6ad5
Binary file not shown.
@@ -1 +1 @@
25eaf7ac2837ade4b3204698d3af41854324ece6ec196daba84bea6bb41f6cde
3cf5fd6ab0ce909b806230e20a7fa46264f85937018cfa68865ec0eeb92d4b72
+3 -3
View File
@@ -5,9 +5,9 @@ wit_bindgen::generate!({
inline: "
package fixture:misbehave;
world misbehave {
include openidea:plugin/plugin-syntax@1.8.0;
export openidea:plugin/providers@1.8.0;
export openidea:plugin/file-actions@1.8.0;
include openidea:plugin/plugin-syntax@1.7.0;
export openidea:plugin/providers@1.7.0;
export openidea:plugin/file-actions@1.7.0;
}
",
path: "../../../../../wit",
Binary file not shown.
@@ -1 +1 @@
f7cd477be706ee5da2656ffb5435cb2b52443aec1270b3b1d804b2bdc6ed8bc8
d349977fd3cbfba92d1d953ac09f65dae5b83b7070723b59553d791c638acf81
Binary file not shown.
@@ -1 +1 @@
c97989134a8e8a0a12013909f4726288b6d9cdaa44d02a58ff505a6cbb1fb9a1
e796039ae2f8e534ffc8c2ed2e47e910aaf593c464525287699cf1daf3e195ef
Binary file not shown.
@@ -1 +1 @@
67660f5ebbfdd3967fabcc0b5c41f215d4a38d9092b91eb0166f12bccfdffffa
5e084fa409bb0641409110d91f203a6f99b451b95cd8067bd1ee68140bf8a892
Binary file not shown.
@@ -1 +1 @@
09c810ce7ebd820ea21525dc9faea99b13e7665b308cb131510e02b86e763c9f
521557c1a062a9b82afa14547bc54b57255cdbed3b2ab4b1b86424dfb8accbdc
Binary file not shown.
@@ -1 +1 @@
9b3a210440560e05326947c7d549ce691315839daea2931c6a78720f7b0d9e7c
5280990269e944aef81ec91b356fadf4aa2fc7e7bb10e4b6842e1dc1c669704a
Binary file not shown.
@@ -1 +1 @@
57d8b00b5853ca789fe704f21e4394d1524dd73dfd27ea4a0b34f4b0e4f1a5f8
ff1391d3c9338b6270fef8af7d7c4a5bc595c391997c220a3a827b4b8a068de8
Binary file not shown.
@@ -1 +1 @@
53e355922819b8034c93070323c7eb2b1505d3bf561dd5de6abb930fe514748c
b78ac0b6fa14401a6379f3e6d07d1398a63d70e882930b3747ec843c0fdb5de7
Binary file not shown.
@@ -1 +1 @@
7af929a1adf5d89929d66e78068253ccde9ad5cedee329c2d4db6b7a5e29790e
55331477c1d5322b0dc35d13fc7f8223bf697f5f21328d340f75519dd1098727
Binary file not shown.
@@ -1 +1 @@
511fab979382c5d64054ba4f5fdfc98d50e252dfd5896265ee589336bf8e7d70
0ae6b7edd0d7cd5025ead4a4afacf0fbe3ebc9548d847f94be0e63585a19747e
Binary file not shown.
@@ -1 +1 @@
444497128ec5e1e01229a93d2340b49ccc5bc27e67defb2c4fe19683042442a3
5d909d6ab2e73fabba2d213a944546ef7e425552852cf7f74e00eb94c9d167ac
+1 -22
View File
@@ -65,26 +65,5 @@ fn bench(c: &mut Criterion) {
g.finish();
}
// No .ignore file: the query's subtree masks must prevent traversal themselves.
fn excluded_subtrees(c: &mut Criterion) {
let dir = tempfile::tempdir().unwrap();
for module in 0..100 {
let root = dir.path().join(format!("modules/m{module}"));
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/main.go"), "needle").unwrap();
for dependency in 0..50 {
let dep = root.join(format!("node_modules/dep{dependency}/lib"));
std::fs::create_dir_all(&dep).unwrap();
std::fs::write(dep.join("index.js"), "needle").unwrap();
}
}
let q = SearchQuery { exclude: vec!["**/node_modules/**".into()], ..query("needle", false) };
assert_eq!(run(dir.path(), &q), 100);
let mut group = c.benchmark_group("search_excluded_subtrees");
group.sample_size(15);
group.bench_function("5k_dependencies", |b| b.iter(|| black_box(run(dir.path(), &q))));
group.finish();
}
criterion_group!(benches, bench, excluded_subtrees);
criterion_group!(benches, bench);
criterion_main!(benches);
+13 -60
View File
@@ -143,22 +143,18 @@ pub(crate) fn run(req: Request<'_>) -> IdeResult<SearchSummary> {
}
if !ctx.ctl.should_stop() {
walk_files(
&on_disk,
&|path| ctx.mask.has_directory_exclusions() && ctx.mask.excludes_dir(&rel_path(ctx.root, path)),
|| {
let mut searcher = searcher_builder.build();
let matcher = matcher.clone();
let ctx = &ctx;
Box::new(move |path| {
if ctx.ctl.should_stop() {
return WalkState::Quit;
}
ctx.search_file(path, &matcher, &mut searcher);
if ctx.ctl.stopped() { WalkState::Quit } else { WalkState::Continue }
})
},
);
walk_files(&on_disk, || {
let mut searcher = searcher_builder.build();
let matcher = matcher.clone();
let ctx = &ctx;
Box::new(move |path| {
if ctx.ctl.should_stop() {
return WalkState::Quit;
}
ctx.search_file(path, &matcher, &mut searcher);
if ctx.ctl.stopped() { WalkState::Quit } else { WalkState::Continue }
})
});
}
let cancelled = ctx.ctl.cancelled.load(Relaxed) || req.cancel.is_cancelled();
@@ -180,11 +176,7 @@ pub(crate) type VisitFile<'s> = Box<dyn FnMut(&Path) -> WalkState + Send + 's>;
/// them, while ignored files elsewhere (`.env`) and hidden files and folders
/// (`.github`, `.eslintrc`) are visited. `.git`, symlinks and archives (jar,
/// zip: browsed, not searched) are never visited.
pub(crate) fn walk_files<'s>(
roots: &[PathBuf],
skip_dir: &'s (dyn Fn(&Path) -> bool + Sync),
mut visitor: impl FnMut() -> VisitFile<'s> + Send,
) {
pub(crate) fn walk_files<'s>(roots: &[PathBuf], mut visitor: impl FnMut() -> VisitFile<'s> + Send) {
let Some((first, rest)) = roots.split_first() else { return };
let mut walk = WalkBuilder::new(first);
for path in rest {
@@ -212,9 +204,6 @@ pub(crate) fn walk_files<'s>(
};
let Some(file_type) = entry.file_type() else { return WalkState::Continue };
if file_type.is_dir() {
if skip_dir(entry.path()) {
return WalkState::Skip;
}
// The walker drops ignored files unseen, so the files of every walked
// directory come from listing it; the walker only picks the directories.
let Ok(children) = std::fs::read_dir(entry.path()) else { return WalkState::Continue };
@@ -606,39 +595,3 @@ mod tests {
}
}
}
#[cfg(test)]
mod walk_tests {
use super::*;
use std::sync::Mutex;
#[test]
fn excluded_subtree_is_not_visited() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("edge/device/build/deep")).unwrap();
std::fs::create_dir_all(root.join("edge/device/src")).unwrap();
std::fs::write(root.join("edge/device/build/deep/generated.go"), "hit").unwrap();
std::fs::write(root.join("edge/device/src/main.go"), "hit").unwrap();
let mask = FileMask::new(&[], &["**/build/**".into()]).unwrap();
let seen = Mutex::new(Vec::new());
walk_files(&[root.to_path_buf()], &|path| mask.excludes_dir(&rel_path(root, path)), || {
let seen = &seen;
Box::new(move |path| {
// No file-level mask here: a visit means the walker entered the subtree.
seen.lock().unwrap().push(rel_path(root, path));
WalkState::Continue
})
});
assert_eq!(*seen.lock().unwrap(), ["edge/device/src/main.go"]);
seen.lock().unwrap().clear();
walk_files(&[root.join("edge/device/build/deep")], &|path| mask.excludes_dir(&rel_path(root, path)), || {
let seen = &seen;
Box::new(move |path| {
seen.lock().unwrap().push(rel_path(root, path));
WalkState::Continue
})
});
assert!(seen.lock().unwrap().is_empty());
}
}
+1 -1
View File
@@ -43,7 +43,7 @@ pub fn scope_files(
on_disk.push(path);
}
}
walk_files(&on_disk, &|path| mask.has_directory_exclusions() && mask.excludes_dir(&rel_path(root, path)), || {
walk_files(&on_disk, || {
let found = &found;
let keep = &keep;
Box::new(move |path| {
-80
View File
@@ -12,7 +12,6 @@ pub struct FileMask {
exclude_name: GlobSet,
exclude_path: GlobSet,
has_include: bool,
exclude_dirs: GlobSet,
}
impl FileMask {
@@ -21,7 +20,6 @@ impl FileMask {
pub fn new(include: &[String], exclude: &[String]) -> IdeResult<Self> {
let mut inc = Lists::default();
let mut exc = Lists::default();
let mut dirs = Lists::default();
for (entries, is_include_list) in [(include, true), (exclude, false)] {
for mask in entries.iter().flat_map(|e| e.split(',')) {
let mask = mask.trim();
@@ -34,20 +32,10 @@ impl FileMask {
}
let target = if is_include_list != negated { &mut inc } else { &mut exc };
target.push(mask)?;
// Only a trailing /** guarantees that every descendant is excluded.
// File masks and inclusion masks cannot safely prune a directory.
if is_include_list == negated
&& !mask.contains('\\')
&& let Some(prefix) = mask.strip_suffix("/**")
&& !prefix.is_empty()
{
dirs.push(&format!("{prefix}/"))?;
}
}
}
Ok(Self {
has_include: inc.count > 0,
exclude_dirs: dirs.path.build().map_err(glob_error)?,
include_name: inc.name.build().map_err(glob_error)?,
include_path: inc.path.build().map_err(glob_error)?,
exclude_name: exc.name.build().map_err(glob_error)?,
@@ -55,20 +43,6 @@ impl FileMask {
})
}
pub(crate) fn has_directory_exclusions(&self) -> bool {
!self.exclude_dirs.is_empty()
}
/// Whether an exclusion covers every file below this directory. This is
/// deliberately conservative; ordinary masks still filter individual files.
pub(crate) fn excludes_dir(&self, rel_path: &str) -> bool {
if self.exclude_dirs.is_empty() {
return false;
}
let path = format!("{rel_path}/");
path.match_indices('/').any(|(i, _)| self.exclude_dirs.is_match(&path[..=i]))
}
/// `rel_path` uses `/` separators.
pub fn matches(&self, rel_path: &str) -> bool {
let name = rel_path.rsplit('/').next().unwrap_or(rel_path);
@@ -159,60 +133,6 @@ mod tests {
assert!(!m.matches("Cargo.lock"));
}
#[test]
fn only_complete_subtree_exclusions_prune_directories() {
let m = mask(&["*.go"], &["**/node_modules/**", "edge/device/build/**"]);
for dir in [
"node_modules",
"edge/node_modules",
"edge/device/build",
"edge/device/build/deep",
"edge/node_modules/pkg/lib",
] {
assert!(m.excludes_dir(dir), "{dir}");
}
for dir in ["edge", "edge/device", "internal/build", "src.go"] {
assert!(!m.excludes_dir(dir), "{dir}");
}
for pattern in ["*.go", "build", "build/*", "build/", "build/**/*.go"] {
assert!(!mask(&[], &[pattern]).excludes_dir("build"), "{pattern}");
}
}
#[test]
fn directory_pruning_preserves_mask_negation_semantics() {
assert!(mask(&["!vendor/**"], &[]).excludes_dir("vendor"));
assert!(!mask(&[], &["!vendor/**"]).excludes_dir("vendor"));
// Exclusions already win over inclusions in FileMask, irrespective of order.
let m = mask(&["vendor/keep.go"], &["vendor/**"]);
assert!(m.excludes_dir("vendor"));
assert!(!m.matches("vendor/keep.go"));
assert!(mask(&[], &["./vendor/**, **/node_modules/**"]).excludes_dir("vendor"));
}
#[test]
fn directory_pruning_uses_the_same_case_rules_as_files() {
let m = mask(&[], &["Vendor/**"]);
let case_insensitive = cfg!(any(target_os = "macos", target_os = "windows"));
assert_eq!(m.excludes_dir("vendor"), case_insensitive);
assert_eq!(m.matches("vendor/a.go"), !case_insensitive);
}
#[test]
fn pruned_directory_descendants_are_already_excluded() {
for pattern in ["vendor/**", "**/build/**", "modules/*/build/**", "[ab]/**", "**/**"] {
let m = mask(&[], &[pattern]);
for dir in ["vendor", "vendor/deep", "build", "a", "modules/a/build", "modules/a/src", "x/build/y"] {
if m.excludes_dir(dir) {
for file in ["main.go", "deep/lib.go", ".hidden/config", "node_modules/a.js"] {
assert!(!m.matches(&format!("{dir}/{file}")), "{pattern}: {dir}/{file}");
}
}
}
}
assert!(!mask(&[], &[r"escaped\*/**"]).excludes_dir("escaped*"));
}
#[test]
fn invalid_glob() {
let err = FileMask::new(&["a[".into()], &[]).unwrap_err();
-39
View File
@@ -797,42 +797,3 @@ fn search_context_reads_non_utf8_files() {
let p = project(&[("a.rs", &gbk("foo(\"新增\"); // 中文 foo\n"))]);
assert_eq!(run_context(p.path(), C::InComments), vec![("a.rs".to_owned(), 0, vec![(17, 20)])]);
}
#[test]
fn subtree_masks_preserve_results_across_search_scopes() {
let p = project(&[
("edge/build/deep/generated.go", b"hit"),
("edge/src/main.go", b"hit"),
("internal/build/build.go", b"hit"),
("edge/build-notes.go", b"hit"),
]);
let q = SearchQuery { exclude: vec!["edge/build/**".into()], ..query("hit") };
let (views, _) = run(p.path(), &q);
assert_eq!(paths(&views), ["edge/build-notes.go", "edge/src/main.go", "internal/build/build.go"]);
let mask = ide_search::FileMask::new(&q.include, &q.exclude).unwrap();
for scope in [
SearchScope::Directory { path: "edge/build".into() },
SearchScope::Files { paths: vec!["edge/build/deep/generated.go".into()] },
] {
let (views, _) = run(p.path(), &SearchQuery { scope: scope.clone(), ..q.clone() });
assert!(views.is_empty());
assert!(ide_search::scope_files(p.path(), &scope, &mask, None, &CancellationToken::new()).unwrap().is_empty());
}
// A file-name exclusion must not prune a same-named directory.
let (views, _) = run(p.path(), &SearchQuery { exclude: vec!["build".into()], ..query("hit") });
assert_eq!(views.len(), 4);
}
#[test]
fn ignore_whitelists_do_not_override_explicit_subtree_masks() {
let p = project(&[
(".gitignore", b"generated/*\n!generated/keep/\n"),
("generated/keep/.gitignore", b"!keep.go\n"),
("generated/keep/keep.go", b"needle"),
("src/main.go", b"needle"),
]);
let (views, _) = run(p.path(), &query("needle"));
assert_eq!(paths(&views), ["generated/keep/keep.go", "src/main.go"]);
let (views, _) = run(p.path(), &SearchQuery { exclude: vec!["generated/**".into()], ..query("needle") });
assert_eq!(paths(&views), ["src/main.go"]);
}
+6 -29
View File
@@ -1410,16 +1410,10 @@ impl Tools {
.ok_or_else(|| IdeError::new(ErrorKind::Io, format!("`{main}` was not found in {}", dl.name)))?;
let rel = found.strip_prefix(&pkg).expect("inside pkg").to_path_buf();
let final_dir = self.root.join(spec.name);
// Downloads made by the IDE itself are not quarantined, but archives may carry the flag.
clear_quarantine(&pkg);
// The previous install is removed in the background once replaced: deleting a large
// tool takes a while, and there is nothing left to cancel by then.
let previous = self.root.join(format!(".previous-{}", spec.name));
let _ = std::fs::remove_dir_all(&previous);
put_in_place(&pkg, &final_dir, &previous, cancel)?;
std::thread::spawn(move || {
let _ = std::fs::remove_dir_all(previous);
});
if final_dir.exists() {
std::fs::remove_dir_all(&final_dir)?;
}
std::fs::rename(&pkg, &final_dir)?;
let target = final_dir.join(&rel);
let bin = final_dir.join("bin");
if is_bundle(main) {
@@ -1434,6 +1428,8 @@ impl Tools {
archive::link_executable(&bin, main, &target)?;
}
}
// Downloads made by the IDE itself are not quarantined, but archives may carry the flag.
clear_quarantine(&final_dir);
self.write_manifest(spec.name, &Manifest { version: Some(dl.version.clone()), source: Some(dl.url.clone()) })
}
@@ -1567,25 +1563,6 @@ fn fetch_verified(
}
/// Removes the quarantine flag archives may carry, so macOS runs what was unpacked.
/// Moves the unpacked `pkg` to `dest`, moving a previous install aside to `aside`.
/// Cancelling stops it until then: the last point where the previous install is untouched.
fn put_in_place(pkg: &Path, dest: &Path, aside: &Path, cancel: &CancellationToken) -> IdeResult<()> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let replaced = dest.exists();
if replaced {
std::fs::rename(dest, aside)?;
}
if let Err(e) = std::fs::rename(pkg, dest) {
if replaced {
let _ = std::fs::rename(aside, dest);
}
return Err(e.into());
}
Ok(())
}
fn clear_quarantine(dir: &Path) {
#[cfg(target_os = "macos")]
{
-1
View File
@@ -249,7 +249,6 @@ mod tests {
("buf", "buf-Windows-arm64.zip"),
("texlab", "texlab-aarch64-windows.zip"),
("neocmakelsp", "neocmakelsp-aarch64-pc-windows-msvc.zip"),
("slint-lsp", "slint-lsp-aarch64-pc-windows-msvc.zip"),
] {
assert_eq!(
select_asset(&[asset], crate::registry::find(tool).unwrap().asset_rules(), platform),
-21
View File
@@ -892,27 +892,6 @@ pub static TOOLS: &[ToolSpec] = &[
homepage: "https://github.com/latex-lsp/texlab",
needs: &[],
},
ToolSpec {
name: "slint-lsp",
display: "Slint LSP",
kind: LanguageServer,
commands: &["slint-lsp"],
method: Method::Release {
source: ReleaseSource::GitHub { repo: "slint-ui/slint" },
assets: &[
any_arch(Mac, "slint-lsp-universal-apple-darwin.tar.gz"),
rule(Linux, X64, "slint-lsp-x86_64-unknown-linux-gnu.tar.gz"),
rule(Linux, Arm64, "slint-lsp-aarch64-unknown-linux-gnu.tar.gz"),
rule(Windows, X64, "slint-lsp-x86_64-pc-windows-msvc.zip"),
rule(Windows, Arm64, "slint-lsp-aarch64-pc-windows-msvc.zip"),
],
locate: &["slint-lsp"],
},
fallback: None,
version: ver("slint-lsp"),
homepage: "https://slint.dev",
needs: &[],
},
ToolSpec {
name: "neocmakelsp",
display: "neocmakelsp (CMake)",
-19
View File
@@ -916,22 +916,3 @@ exit 1
let args = fs::read_to_string(log).unwrap();
assert!(args.contains(&format!("--python {}", sdk.join("python3.14").display())), "{args}");
}
#[test]
fn a_cancelled_install_leaves_the_previous_one_in_place() {
let tmp = tempfile::tempdir().unwrap();
let (pkg, dest, aside) = (tmp.path().join("pkg"), tmp.path().join("tool"), tmp.path().join(".previous-tool"));
fs::create_dir_all(&pkg).unwrap();
fs::write(pkg.join("new"), "").unwrap();
fs::create_dir_all(&dest).unwrap();
fs::write(dest.join("old"), "").unwrap();
let cancel = CancellationToken::new();
cancel.cancel();
let err = put_in_place(&pkg, &dest, &aside, &cancel).unwrap_err();
assert_eq!(err.kind, ErrorKind::Cancelled);
assert!(dest.join("old").exists() && pkg.join("new").exists() && !aside.exists());
put_in_place(&pkg, &dest, &aside, &CancellationToken::new()).unwrap();
assert!(dest.join("new").exists() && !dest.join("old").exists());
assert!(aside.join("old").exists(), "the previous install is moved aside");
}
+1 -1
View File
@@ -48,7 +48,7 @@ openidea-server --stdio | --connect <host> <port> | --version
### Installing it on the remote machine
The desktop picks the server binary for the remote OS and architecture (from `uname -sm`): `OPENIDEA_SERVER_BINARY` if set, then the one next to the app when the platforms match; otherwise the release build of this version for that platform, downloaded once into `<data dir>/server/<version>/<os>-<arch>/`. Offline, it falls back to a build of this version already on the host, then to the unversioned `<data dir>/server/<os>-<arch>/openidea-server`. The server reports its version in `initialize`; a command missing on a server of another version fails with both versions named. It uploads the binary into `~/.openidea/server/` on the remote host, one folder per version, keyed by a content hash, and removes other builds of that version. CI keeps a static Linux (musl) build working.
The desktop picks the server binary for the remote OS and architecture (from `uname -sm`): `OPENIDEA_SERVER_BINARY` if set, then `<data dir>/server/<os>-<arch>/openidea-server`, then the one next to the app when the platforms match; otherwise it downloads the release build for that platform once into its data dir. It uploads the binary into `~/.openidea/server/` on the remote host, one folder per version, keyed by a content hash, and removes other builds of that version. CI keeps a static Linux (musl) build working.
## The protocol
+6 -9
View File
@@ -66,18 +66,15 @@ When the desktop machine can't reach GitHub, give it the server binary yourself.
this order:
1. The file named by the `OPENIDEA_SERVER_BINARY` environment variable, if it is set when OpenIDEA starts.
2. An `openidea-server` next to the OpenIDEA executable, when the host has the same platform as the desktop.
3. `server/<version>/<os>-<arch>/openidea-server` in the desktop's
[local data directory](/en/reference/data-directories), for example `server/0.1.44/linux-x86_64/openidea-server`.
This is also where the desktop keeps the server it downloads, so a file put there is used instead of downloading.
2. `server/<os>-<arch>/openidea-server` in the desktop's [local data directory](/en/reference/data-directories), for
example `server/linux-x86_64/openidea-server`.
3. An `openidea-server` next to the OpenIDEA executable, when the host has the same platform as the desktop.
Download `openidea-server-<os>-<arch>.gz` from the release that matches your OpenIDEA version, unpack it with
`gunzip`, and put it in the version's folder. After an update the desktop looks in the new version's folder, so an
older server is never used by mistake.
`gunzip`, and put it in one of these places. Use the same version as the desktop app; the desktop refuses a server
that speaks a different protocol.
If nothing can be downloaded, the desktop uses a build of the current version already installed on the host, and then
`server/<os>-<arch>/openidea-server` (the place earlier versions used), which may be another version: commands added
since then report that the server does not match the app.
If the host already has a build of the current version installed, the desktop uses it when nothing can be downloaded.
### Build from source
-1
View File
@@ -24,7 +24,6 @@ The same page also has:
- **Accent color**: the UI's accent color.
- **Different tool window background**: set tool windows apart from the editor.
- **Background Image**: an image shown through the project window, with its **Opacity**, **Fill** (scale, original size or tile) and **Anchor**.
- **Accessibility**: **Use contrast scrollbars** and **Adjust colors for red-green vision deficiency** (for protanopia and deuteranopia).
- **Compact mode** under **UI Options**.
-1
View File
@@ -163,7 +163,6 @@ The table lists every built-in language definition. Server and debugger names in
| Scala | `scala` | `.scala`, `.sc`, `.sbt`, `.mill` | Yes | Yes | metals | — |
| SCSS | `scss` | `.scss` | Yes | — | **vscode-css-language-server** | — |
| Shell Script | `shellscript` | `.sh`, `.bash`, `.zsh`, `.ksh`, `.ash`, … | Yes | Yes | **bash-language-server** | **bash-debug** |
| Slint | `slint` | `.slint` | Yes | — | **slint-lsp** | — |
| SQL | `sql` | `.sql`, `.ddl`, `.dml`, `.psql`, `.pgsql`, … | Yes | — | **sqls** | — |
| Svelte | `svelte` | `.svelte` | Yes | — | **svelte-language-server** + **oxlint** | — |
| Swift | `swift` | `.swift`, `.swiftinterface` | Yes | Yes | sourcekit-lsp | lldb-dap |
+2 -3
View File
@@ -3,7 +3,7 @@ title: "API versions and compatibility"
description: "engines.openidea, the plugin API versions from 1.0 to 1.6, and the rules that keep released plugins loading in newer IDEs."
---
The plugin API has one version number: the version of the WIT package in [`wit/`](https://github.com/l0ng-ai/openidea/tree/main/wit) (`openidea:plugin@1.8.0` on `main`). The host's engine version and the SDK's `API_VERSION` always agree with it, and the SDK carries an identical copy of the WIT files.
The plugin API has one version number: the version of the WIT package in [`wit/`](https://github.com/l0ng-ai/openidea/tree/main/wit) (`openidea:plugin@1.7.0` on `main`). The host's engine version and the SDK's `API_VERSION` always agree with it, and the SDK carries an identical copy of the WIT files.
<Note>
The plugin system is a preview. The compatibility rules below hold for released 1.x versions; what is still unreleased
@@ -21,8 +21,7 @@ The plugin API has one version number: the version of the WIT package in [`wit/`
| 1.4.0 | OpenIDEA 0.1.40 | `net.http:`, `ipc:`, `ide.commands` and `database.sources` capabilities; editor and project snapshots, decorations, text search, Problems, Git log/blame/diff; output channels; OAuth loopback; runs, terminal sessions, run configurations, test result parsing and run targets; formatting and other language features; embedded languages; snippets; more menu locations; database sources; MCP tools |
| 1.5.0 | OpenIDEA 0.1.41 | `Project::notify` (a notification in one project's window), the `net:ask` capability, run target validation, per-command run target working-directory arguments |
| 1.6.0 | OpenIDEA 0.1.43 | Bounded, project-bound background WebSockets for event-stream plugins |
| 1.7.0 | OpenIDEA 0.1.44 | `views.open-diff`: two texts in the diff viewer, such as a review's file before and after |
| 1.8.0 | In development on `main` | The `problems-changed` workbench event: the host keeps every producer's diagnostics per file, so an annotation follows the file rather than the invoking window |
| 1.7.0 | In development on `main` | `views.open-diff`: two texts in the diff viewer, such as a review's file before and after |
The full list of what each version added is in [`plugins/README.md`](https://github.com/l0ng-ai/openidea/blob/main/plugins/README.md).
-1
View File
@@ -109,7 +109,6 @@ Settings › Appearance & Behavior › Appearance, unless noted.
| `uiFontFallback` | `""` | **UI fallback font**. |
| `contrastScrollbars` | `false` | **Use contrast scrollbars**. |
| `redGreenVision` | `false` | **Adjust colors for red-green vision deficiency**. |
| `backgroundImage` | `opacity: 15` | **Background Image**: `path` (empty: none), `opacity` (0–100), `fill` (`scale` / `plain` / `tile`), `anchor` (`center`, `top-left`, …). |
| `compactMode` | `false` | **Compact mode**. |
| `showMainToolbar` / `showStatusBar` | `true` / `true` | **Show main toolbar**, **Show status bar**. |
| `navigationBar` | `"statusBar"` | **Navigation bar**: `top`, `statusBar` or `hidden`. |
+1 -1
View File
@@ -48,7 +48,7 @@ openidea-server --stdio | --connect <host> <port> | --version
### 安装到远程机器
桌面端根据远程的操作系统和架构(来自 `uname -sm`)选择服务端二进制:先看是否设置了 `OPENIDEA_SERVER_BINARY`,平台相同时再用应用旁边自带的那个;否则使用该平台当前版本的 release 构建,只下载一次,放在 `<data dir>/server/<版本>/<os>-<arch>/`。离线时先用主机上已装的当前版本构建,再用不带版本的 `<data dir>/server/<os>-<arch>/openidea-server`。服务端在 `initialize` 中回报版本;版本不同的服务端缺少某个命令时,错误会写明两边的版本。之后把二进制上传到远程主机的 `~/.openidea/server/`,每个版本一个目录,按内容哈希区分,并删除同版本的其他构建。CI 会持续验证静态 Linux(musl)构建。
桌面端根据远程的操作系统和架构(来自 `uname -sm`)选择服务端二进制:先看是否设置了 `OPENIDEA_SERVER_BINARY`,再看 `<data dir>/server/<os>-<arch>/openidea-server`,平台相同时再用应用旁边自带的那个;都没有就把该平台的 release 构建下载到数据目录(只下载一次)。之后把二进制上传到远程主机的 `~/.openidea/server/`,每个版本一个目录,按内容哈希区分,并删除同版本的其他构建。CI 会持续验证静态 Linux(musl)构建。
## 协议
+5 -7
View File
@@ -57,15 +57,13 @@ SSH 执行的命令默认只有很精简的环境,所以服务端启动时会
本机访问不了 GitHub 时,可以自己提供服务端二进制。桌面端按以下顺序查找:
1. 环境变量 `OPENIDEA_SERVER_BINARY` 指向的文件(需在启动 OpenIDEA 时已设置)。
2. 主机与本机平台相同时,OpenIDEA 可执行文件旁边的 `openidea-server`。
3. 本机[本地数据目录](/zh/reference/data-directories)中的 `server/<版本>/<os>-<arch>/openidea-server`,例如
`server/0.1.44/linux-x86_64/openidea-server`。桌面端下载的服务端也放在这里,所以放在这里的文件会代替下载。
2. 本机[本地数据目录](/zh/reference/data-directories)中的 `server/<os>-<arch>/openidea-server`,例如 `server/linux-x86_64/openidea-server`。
3. 主机与本机平台相同时,OpenIDEA 可执行文件旁边的 `openidea-server`。
从与你的 OpenIDEA 版本对应的 Release 下载 `openidea-server-<os>-<arch>.gz`,用 `gunzip` 解压后放到该版本的目录。
升级后桌面端会到新版本的目录查找,不会误用旧版服务端。
从与你的 OpenIDEA 版本对应的 Release 下载 `openidea-server-<os>-<arch>.gz`,用 `gunzip` 解压后放到上述任一位置。版本必须与桌面端一致:
协议版本不同的服务端会被拒绝。
无法下载时,桌面端先用主机上已装的当前版本构建,再用 `server/<os>-<arch>/openidea-server`(早期版本使用的位置),
它可能是其他版本:之后新增的命令会提示服务端与应用版本不一致。
如果主机上已经装有当前版本的某个构建,在无法下载时桌面端会直接使用它。
### 从源码构建
Loaded 100 of 350 files, more files were not shown because too many files have changed in this diff. Show more