mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 16:08:05 +00:00
Workspaces open several local project folders in one IDE session (File › New / Open / Manage Workspace…, or Attach when opening a project, which creates a workspace automatically for a plain project window). Member folders are the Project view roots and take part in indexing, Find in Files, Git, language servers, run configurations and builds; agent tasks choose which member they work in. Window › Merge All Project Windows groups project windows into one window with project tabs (switch, reorder, close, move to a new window), restored on reopen. Closes #241 Closes #198 Closes #286
374 lines
14 KiB
Rust
374 lines
14 KiB
Rust
//! Background project index (files + symbols).
|
|
//!
|
|
//! Indexing never blocks the UI: queries answer from whatever is indexed so far
|
|
//! and `IdeEvent::IndexState` tells the UI when it is complete ("dumb mode").
|
|
|
|
use ide_core::{IdeResult, MatchIndices, Range, SharedSink, SymbolKind};
|
|
use ide_language::{LanguageRegistry, Syntax};
|
|
use parking_lot::Mutex;
|
|
use serde::{Deserialize, Serialize};
|
|
use specta::Type;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::Ordering;
|
|
use std::thread::JoinHandle;
|
|
use std::time::Duration;
|
|
|
|
mod data;
|
|
mod filter;
|
|
mod matching;
|
|
mod store;
|
|
mod worker;
|
|
|
|
pub use worker::MAX_SYMBOL_FILE;
|
|
use worker::{Job, MAX_RECENT, Shared};
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct FileMatch {
|
|
pub path: ide_core::IpcPath,
|
|
/// Path relative to the project root, as displayed.
|
|
pub rel_path: String,
|
|
pub score: u32,
|
|
/// Matched char indices within `rel_path`.
|
|
pub indices: MatchIndices,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SymbolMatch {
|
|
pub name: String,
|
|
pub kind: SymbolKind,
|
|
pub container: Option<String>,
|
|
pub path: ide_core::IpcPath,
|
|
pub rel_path: String,
|
|
pub range: Range,
|
|
pub score: u32,
|
|
/// Matched char indices within `name`.
|
|
pub indices: MatchIndices,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub enum SymbolFilter {
|
|
All,
|
|
/// Go to Class (⌘O): classes, interfaces, structs, enums, traits, type aliases.
|
|
Types,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, Type)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct IndexStatus {
|
|
pub indexing: bool,
|
|
pub files: u32,
|
|
pub symbols: u32,
|
|
}
|
|
|
|
pub struct ProjectIndex {
|
|
shared: Arc<Shared>,
|
|
worker: Mutex<Option<JoinHandle<()>>>,
|
|
root: PathBuf,
|
|
}
|
|
|
|
/// Folders indexed alongside the internal state root.
|
|
#[derive(Clone, PartialEq)]
|
|
pub struct IndexRoots {
|
|
pub include_root: bool,
|
|
pub folders: Vec<PathBuf>,
|
|
}
|
|
|
|
impl From<Vec<PathBuf>> for IndexRoots {
|
|
fn from(folders: Vec<PathBuf>) -> Self {
|
|
Self { include_root: true, folders }
|
|
}
|
|
}
|
|
|
|
impl ProjectIndex {
|
|
/// Opens (or creates) the persistent index at `cache_dir` and starts a
|
|
/// background full scan that skips files whose content hash is unchanged.
|
|
/// `content_roots` are folders outside `root` indexed with it (not persisted).
|
|
pub fn open(
|
|
root: &Path,
|
|
cache_dir: &Path,
|
|
content_roots: impl Into<IndexRoots>,
|
|
excludes: Vec<String>,
|
|
languages: Arc<LanguageRegistry>,
|
|
syntax: Arc<Syntax>,
|
|
sink: SharedSink,
|
|
) -> IdeResult<Arc<Self>> {
|
|
let root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_owned());
|
|
if !root.is_dir() {
|
|
return Err(ide_core::IdeError::not_found(format!("{} is not a directory", root.display())));
|
|
}
|
|
let store = store::Store::open(cache_dir, &root, &syntax.fingerprint());
|
|
let filter = filter::PathFilter::new(&root, &content_roots.into(), &excludes);
|
|
let shared = Arc::new(Shared::new(filter, languages, syntax, sink, store));
|
|
Self::start(shared, root, true)
|
|
}
|
|
|
|
/// An index that never scans or persists anything (a window editing loose
|
|
/// files): queries answer empty, recent files are kept in memory only.
|
|
pub fn unindexed(
|
|
root: &Path,
|
|
languages: Arc<LanguageRegistry>,
|
|
syntax: Arc<Syntax>,
|
|
sink: SharedSink,
|
|
) -> IdeResult<Arc<Self>> {
|
|
let root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_owned());
|
|
let filter = filter::PathFilter::new(&root, &Vec::new().into(), &[]);
|
|
let shared = Arc::new(Shared::new(filter, languages, syntax, sink, None));
|
|
Self::start(shared, root, false)
|
|
}
|
|
|
|
fn start(shared: Arc<Shared>, root: PathBuf, scan: bool) -> IdeResult<Arc<Self>> {
|
|
let worker = {
|
|
let shared = shared.clone();
|
|
std::thread::Builder::new()
|
|
.name("openidea-index".into())
|
|
.spawn(move || shared.run(scan))
|
|
.map_err(|e| ide_core::IdeError::internal(format!("cannot start indexer: {e}")))?
|
|
};
|
|
Ok(Arc::new(Self { shared, worker: Mutex::new(Some(worker)), root }))
|
|
}
|
|
|
|
/// Changes what is indexed besides the root (content roots) and the exclude
|
|
/// globs, rescanning in the background (unchanged files are not re-read).
|
|
/// Returns whether anything changed.
|
|
pub fn set_scope(&self, content_roots: impl Into<IndexRoots>, excludes: Vec<String>) -> bool {
|
|
let content_roots = content_roots.into();
|
|
if self.shared.filter().same_config(&content_roots, &excludes) {
|
|
return false;
|
|
}
|
|
self.shared.set_filter(filter::PathFilter::new(&self.root, &content_roots, &excludes));
|
|
self.shared.enqueue(Job::Rescan);
|
|
true
|
|
}
|
|
|
|
/// File types changed (the registry the index shares was updated): re-parses
|
|
/// the files whose language under `before` differs from the current one.
|
|
pub fn languages_changed(&self, before: Arc<LanguageRegistry>) {
|
|
self.shared.enqueue(Job::Reparse(before));
|
|
}
|
|
|
|
pub fn status(&self) -> IndexStatus {
|
|
let d = self.shared.data.read();
|
|
IndexStatus {
|
|
indexing: self.shared.indexing.load(Ordering::SeqCst),
|
|
files: d.file_count,
|
|
symbols: d.symbol_count,
|
|
}
|
|
}
|
|
|
|
/// Fuzzy file search. IDEA-like ranking: file-name matches beat directory
|
|
/// matches; `/` in the query matches path segments; recently opened files boost.
|
|
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 { paths: &recent }, query, limit)
|
|
}
|
|
|
|
/// Fuzzy symbol search; CamelHump aware (`OCo` → `OrderController`).
|
|
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 { paths: &recent }, query, filter, limit)
|
|
}
|
|
|
|
/// Symbols named exactly `name` (of `kind`, if given): `.card` selectors for `class="card"`.
|
|
pub fn symbols_named(&self, name: &str, kind: Option<SymbolKind>, limit: usize) -> Vec<SymbolMatch> {
|
|
matching::symbols_named(&self.shared.data.read(), name, kind, limit)
|
|
}
|
|
|
|
/// Incrementally re-indexes changed paths (called from the vfs watcher).
|
|
/// Cheap: the work is queued for the indexing thread.
|
|
pub fn update(&self, changed: &[PathBuf], removed: &[PathBuf]) {
|
|
let changed: Vec<PathBuf> = changed.iter().filter_map(|p| self.normalize(p)).collect();
|
|
let removed: Vec<PathBuf> = removed.iter().filter_map(|p| self.normalize(p)).collect();
|
|
if !removed.is_empty() {
|
|
self.shared.enqueue(Job::Removed(removed));
|
|
}
|
|
if !changed.is_empty() {
|
|
self.shared.enqueue(Job::Changed(changed));
|
|
}
|
|
}
|
|
|
|
/// Boost for "recent files" ranking.
|
|
/// Records a recently opened file; a virtual document's URI (library
|
|
/// class) is kept verbatim, like IDEA lists library classes in Recent Files.
|
|
pub fn mark_recent(&self, path: &Path) {
|
|
let path = match path.to_str() {
|
|
Some(s) if ide_core::text::is_virtual_uri(s) => path.to_owned(),
|
|
_ => match self.normalize(path) {
|
|
Some(p) => p,
|
|
None => return,
|
|
},
|
|
};
|
|
{
|
|
let mut r = self.shared.recent.lock();
|
|
r.retain(|p| p != &path);
|
|
r.insert(0, path);
|
|
r.truncate(MAX_RECENT);
|
|
}
|
|
self.shared.enqueue(Job::SaveRecent);
|
|
}
|
|
|
|
/// Drops a file from Recent Files (Delete in the ⌘E popup).
|
|
pub fn remove_recent(&self, path: &Path) {
|
|
let path = match path.to_str() {
|
|
Some(s) if ide_core::text::is_virtual_uri(s) => path.to_owned(),
|
|
_ => match self.normalize(path) {
|
|
Some(p) => p,
|
|
None => return,
|
|
},
|
|
};
|
|
{
|
|
let mut r = self.shared.recent.lock();
|
|
let before = r.len();
|
|
r.retain(|p| p != &path);
|
|
if r.len() == before {
|
|
return;
|
|
}
|
|
}
|
|
self.shared.enqueue(Job::SaveRecent);
|
|
}
|
|
|
|
/// Recently opened files, most recent first; files deleted since (also while
|
|
/// the IDE was closed) are left out. Virtual documents are always listed.
|
|
pub fn recent_files(&self) -> Vec<PathBuf> {
|
|
let recent = self.shared.recent.lock().clone();
|
|
let filter = self.shared.filter();
|
|
recent
|
|
.into_iter()
|
|
.filter(|p| {
|
|
p.to_str().is_some_and(ide_core::text::is_virtual_uri)
|
|
|| (p.exists() && (!p.starts_with(&self.root) || filter.contains(p)))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Blocks until the initial scan and every queued update has been applied
|
|
/// (tests, benchmarks, headless tools). Returns `false` on timeout.
|
|
pub fn wait_idle(&self, timeout: Duration) -> bool {
|
|
self.shared.wait_idle(timeout)
|
|
}
|
|
|
|
/// Releases spare memory and compacts the on-disk cache ("Clear caches").
|
|
/// Blocking; call off the UI thread.
|
|
pub fn compact(&self) {
|
|
self.shared.data.write().shrink();
|
|
if let Some(store) = &self.shared.store
|
|
&& let Err(e) = store.compact()
|
|
{
|
|
tracing::debug!("index compact: {e}");
|
|
}
|
|
}
|
|
|
|
pub fn root(&self) -> &Path {
|
|
&self.root
|
|
}
|
|
|
|
/// Absolute path inside the root (or a content root), or `None` for paths outside the project.
|
|
fn normalize(&self, path: &Path) -> Option<PathBuf> {
|
|
let filter = self.shared.filter();
|
|
let root = &self.root;
|
|
let path = if path.is_absolute() { path.to_owned() } else { root.join(path) };
|
|
let path = if filter.contains(&path) {
|
|
path
|
|
} else {
|
|
// Watchers / the UI may report non-canonical spellings (`/tmp` vs `/private/tmp`).
|
|
match dunce::canonicalize(&path) {
|
|
Ok(p) => p,
|
|
Err(_) => {
|
|
let parent = dunce::canonicalize(path.parent()?).ok()?;
|
|
parent.join(path.file_name()?)
|
|
}
|
|
}
|
|
};
|
|
(filter.contains(&path) && filter.rel(&path).is_some()).then_some(path)
|
|
}
|
|
}
|
|
|
|
impl Drop for ProjectIndex {
|
|
fn drop(&mut self) {
|
|
self.shared.cancel();
|
|
if let Some(handle) = self.worker.lock().take() {
|
|
let _ = handle.join();
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|
|
|
|
/// Synthetic in-memory index for benchmarks (`benches/index.rs`); not a stable API.
|
|
#[doc(hidden)]
|
|
pub mod bench_support {
|
|
use super::*;
|
|
use crate::data::{FileEntry, FileRecord, IndexData, StoredSymbol};
|
|
|
|
pub struct SyntheticIndex {
|
|
data: IndexData,
|
|
recent: Vec<PathBuf>,
|
|
}
|
|
|
|
const DIRS: &[&str] = &["src", "lib", "app", "core", "ui", "server", "client", "util", "test", "model"];
|
|
const WORDS: &[&str] = &[
|
|
"order", "user", "account", "payment", "invoice", "session", "cache", "event", "report", "search", "index",
|
|
"query", "parser", "token", "render", "layout", "widget", "store", "action", "service",
|
|
];
|
|
const SUFFIX: &[&str] = &["Controller", "Service", "Repository", "Factory", "Handler", "View", "Model", "Util"];
|
|
|
|
impl SyntheticIndex {
|
|
/// `files` files spread over nested directories, each with `symbols_per_file` symbols.
|
|
pub fn new(files: usize, symbols_per_file: usize) -> Self {
|
|
let mut data = IndexData::default();
|
|
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
|
|
let mut next = |n: usize| {
|
|
seed ^= seed << 13;
|
|
seed ^= seed >> 7;
|
|
seed ^= seed << 17;
|
|
(seed % n as u64) as usize
|
|
};
|
|
for i in 0..files {
|
|
let depth = 1 + next(4);
|
|
let mut rel = String::new();
|
|
for _ in 0..depth {
|
|
rel.push_str(DIRS[next(DIRS.len())]);
|
|
rel.push('/');
|
|
}
|
|
let w = WORDS[next(WORDS.len())];
|
|
let s = SUFFIX[next(SUFFIX.len())];
|
|
rel.push_str(&format!("{w}{s}{i}.rs"));
|
|
let symbols = (0..symbols_per_file)
|
|
.map(|k| StoredSymbol {
|
|
name: format!("{}{}{k}", WORDS[next(WORDS.len())], SUFFIX[next(SUFFIX.len())]),
|
|
kind: if k % 3 == 0 { SymbolKind::Class } else { SymbolKind::Method },
|
|
container: (k % 3 != 0).then(|| format!("{w}{s}")),
|
|
range: Range::default(),
|
|
})
|
|
.collect();
|
|
let record = FileRecord { symbols, ..Default::default() };
|
|
data.insert(FileEntry::new(PathBuf::from("/bench").join(&rel), rel, record));
|
|
}
|
|
let recent = data.paths().take(MAX_RECENT).cloned().collect();
|
|
Self { data, recent }
|
|
}
|
|
|
|
pub fn file_count(&self) -> u32 {
|
|
self.data.file_count
|
|
}
|
|
|
|
pub fn symbol_count(&self) -> u32 {
|
|
self.data.symbol_count
|
|
}
|
|
|
|
pub fn find_files(&self, query: &str, limit: usize) -> Vec<FileMatch> {
|
|
matching::find_files(&self.data, &matching::Recents { paths: &self.recent }, query, limit)
|
|
}
|
|
|
|
pub fn find_symbols(&self, query: &str, filter: SymbolFilter, limit: usize) -> Vec<SymbolMatch> {
|
|
matching::find_symbols(&self.data, &matching::Recents { paths: &self.recent }, query, filter, limit)
|
|
}
|
|
}
|
|
}
|