дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
Plugin API 1.2 gains an optional document-indexer export (plugin-indexing world). A plugin returns per document the keys it defines and refers to, aliases between key prefixes and gutter markers; the host indexes a trusted project in cancellable background batches, keeps the project-wide graph, and answers Go to Declaration / Ctrl-click and multi-target gutter markers from memory without calling the plugin again. Edits and file changes invalidate the affected entries; reload, disable or a failing runtime drops the plugin's graph. The interface is limited to cross-file associations: icons chosen by file content and embedded-language regions are separate concerns for later interfaces. The plugin navigation choosers' titles are translated. Co-authored-by: qjlqyf <[email protected]>
957 рядки
36 KiB
Rust
957 рядки
36 KiB
Rust
use crate::editorconfig::{EditorConfig, EditorConfigProps};
|
|
use crate::file_defaults::FileDefaults;
|
|
use ide_core::error::ErrorKind;
|
|
use ide_core::event::NotifyLevel;
|
|
use ide_core::{ChangeSet, DocId, IdeError, IdeEvent, IdeResult, LanguageId, SharedSink, TextEdit};
|
|
use ide_text::{AppliedChange, ByteEdit, Document, LineEnding};
|
|
use parking_lot::{Mutex, ReentrantMutex, RwLock};
|
|
use serde::{Deserialize, Serialize};
|
|
use specta::Type;
|
|
use std::collections::HashMap;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
|
|
/// What the UI needs to show an opened document.
|
|
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct DocumentInfo {
|
|
pub id: DocId,
|
|
pub path: ide_core::IpcPath,
|
|
pub text: String,
|
|
pub version: u32,
|
|
pub language: LanguageId,
|
|
pub encoding: String,
|
|
pub line_ending: LineEnding,
|
|
pub read_only: bool,
|
|
pub dirty: bool,
|
|
/// Larger than `ide_vfs::LARGE_FILE_THRESHOLD`: language servers, syntax
|
|
/// structure and folding are disabled; the editor should drop heavy features.
|
|
#[specta(optional)]
|
|
pub large: bool,
|
|
/// Effective `.editorconfig` properties, when any section applies.
|
|
#[specta(optional)]
|
|
pub editorconfig: Option<EditorConfigProps>,
|
|
/// Set for read-only virtual documents (library classes a language server
|
|
/// provides, e.g. `jdt://…`); `path` then holds the same URI.
|
|
#[specta(optional)]
|
|
pub uri: Option<String>,
|
|
/// CodeMirror mode replacing the language's own one (an Angular project's
|
|
/// HTML templates).
|
|
#[specta(optional)]
|
|
pub ui_mode: Option<String>,
|
|
}
|
|
|
|
/// Current text and identity of a document, for analyses (outline, folding).
|
|
#[derive(Clone, Debug)]
|
|
pub struct TextSnapshot {
|
|
pub version: u32,
|
|
pub language: LanguageId,
|
|
pub large: bool,
|
|
pub text: String,
|
|
}
|
|
|
|
/// Language of a file from its path and first line (hashbangs).
|
|
pub type DetectLanguage = dyn Fn(&Path, Option<&str>) -> LanguageId + Send + Sync;
|
|
|
|
/// CodeMirror mode of a file replacing its language's own one; `None` keeps it.
|
|
pub type DetectUiMode = dyn Fn(&Path, &LanguageId) -> Option<String> + Send + Sync;
|
|
|
|
/// Registry of open documents keyed by path, with ref-counting so split
|
|
/// editors on the same file share one buffer.
|
|
///
|
|
/// Every document has its own lock; operations on one document are serialized
|
|
/// and observers see them in version order.
|
|
pub struct Documents {
|
|
/// Workspace transactions hold this across preflight/commit/rollback; ordinary
|
|
/// operations re-enter it while accessing the individual document locks.
|
|
operations: ReentrantMutex<()>,
|
|
workspace_edits: Arc<Mutex<workspace_edit::History>>,
|
|
workspace_edit_active: std::sync::atomic::AtomicBool,
|
|
registry: Mutex<Registry>,
|
|
observers: RwLock<Vec<Arc<dyn DocumentObserver>>>,
|
|
sink: SharedSink,
|
|
detect_language: Box<DetectLanguage>,
|
|
detect_ui_mode: Box<DetectUiMode>,
|
|
editorconfig: EditorConfig,
|
|
defaults: RwLock<FileDefaults>,
|
|
}
|
|
|
|
/// Hook so other subsystems (LSP sync, local history) observe document life-cycle
|
|
/// without `ide-workspace` depending on them.
|
|
///
|
|
/// Callbacks run synchronously while the document is locked (which keeps them
|
|
/// in version order); they must be quick and must not call back into
|
|
/// [`Documents`] for the same document. Binary and too-large documents are
|
|
/// not reported to observers.
|
|
pub trait DocumentObserver: Send + Sync {
|
|
fn opened(&self, _info: &DocumentInfo) {}
|
|
fn changed(
|
|
&self,
|
|
_doc: DocId,
|
|
_path: &Path,
|
|
_version: u32,
|
|
_changes: &[AppliedChange],
|
|
_full_text: &dyn Fn() -> String,
|
|
) {
|
|
}
|
|
/// The same changes as the preceding [`DocumentObserver::changed`] call, in
|
|
/// byte coordinates of the `\n`-normalized text (incremental re-parsing).
|
|
fn edited(&self, _doc: DocId, _version: u32, _edits: &[ByteEdit]) {}
|
|
fn saved(&self, _doc: DocId, _path: &Path, _text: &str) {}
|
|
fn closed(&self, _doc: DocId, _path: &Path) {}
|
|
}
|
|
|
|
mod workspace_edit;
|
|
|
|
type Slot = Arc<Mutex<DocState>>;
|
|
|
|
#[derive(Default)]
|
|
struct Registry {
|
|
docs: HashMap<DocId, RegEntry>,
|
|
/// Both the path as given and its canonical form map to the document.
|
|
by_key: HashMap<PathBuf, DocId>,
|
|
}
|
|
|
|
struct RegEntry {
|
|
slot: Slot,
|
|
refs: u32,
|
|
keys: Vec<PathBuf>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct DocState {
|
|
path: PathBuf,
|
|
doc: Document,
|
|
language: LanguageId,
|
|
ui_mode: Option<String>,
|
|
encoding: String,
|
|
has_bom: bool,
|
|
native_to_ascii: bool,
|
|
read_only: bool,
|
|
large: bool,
|
|
editorconfig: Option<EditorConfigProps>,
|
|
/// Observers were told about this document (not binary / too large).
|
|
observed: bool,
|
|
binary: bool,
|
|
dirty: bool,
|
|
/// Disk state we last loaded or wrote; changes matching it are our own.
|
|
disk_mtime: f64,
|
|
disk_len: u64,
|
|
closed: bool,
|
|
/// URI of a virtual document (no file behind it).
|
|
uri: Option<String>,
|
|
}
|
|
|
|
fn keys_for(path: &Path) -> Vec<PathBuf> {
|
|
let mut keys = vec![path.to_path_buf()];
|
|
if let Ok(canonical) = dunce::canonicalize(path)
|
|
&& canonical != path
|
|
{
|
|
keys.push(canonical);
|
|
}
|
|
keys
|
|
}
|
|
|
|
fn absolute(path: &Path) -> PathBuf {
|
|
if path.is_absolute() {
|
|
path.to_path_buf()
|
|
} else {
|
|
std::env::current_dir().map(|d| d.join(path)).unwrap_or_else(|_| path.to_path_buf())
|
|
}
|
|
}
|
|
|
|
fn disk_len(path: &Path) -> u64 {
|
|
std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)
|
|
}
|
|
|
|
fn read_only_error(path: &Path) -> IdeError {
|
|
IdeError::new(ErrorKind::PermissionDenied, format!("{} is read-only", path.display()))
|
|
}
|
|
|
|
impl Documents {
|
|
pub fn new(sink: SharedSink, detect_language: Box<DetectLanguage>) -> Self {
|
|
Self {
|
|
operations: ReentrantMutex::new(()),
|
|
workspace_edits: Arc::default(),
|
|
workspace_edit_active: std::sync::atomic::AtomicBool::new(false),
|
|
registry: Mutex::default(),
|
|
observers: RwLock::default(),
|
|
sink,
|
|
detect_language,
|
|
detect_ui_mode: Box::new(|_, _| None),
|
|
editorconfig: EditorConfig::new(),
|
|
defaults: RwLock::default(),
|
|
}
|
|
}
|
|
|
|
/// Detects per-file CodeMirror modes (framework templates) besides languages.
|
|
pub fn with_ui_mode_detection(mut self, detect_ui_mode: Box<DetectUiMode>) -> Self {
|
|
self.detect_ui_mode = detect_ui_mode;
|
|
self
|
|
}
|
|
|
|
/// The `DocumentInfo::ui_mode` `path` gets when opened, without opening it
|
|
/// (previews of other files).
|
|
pub fn ui_mode_of(&self, path: &Path) -> Option<String> {
|
|
(self.detect_ui_mode)(path, &(self.detect_language)(path, None))
|
|
}
|
|
|
|
/// Settings-derived defaults for new files, fallback decoding and EditorConfig support.
|
|
pub fn set_file_defaults(&self, defaults: FileDefaults) {
|
|
*self.defaults.write() = defaults;
|
|
}
|
|
|
|
/// Re-decode only clean properties buffers after the conversion setting changes.
|
|
/// Runs on the host's blocking pool; dirty documents retain their current codec.
|
|
pub fn reload_properties(&self) {
|
|
for id in self.open_documents() {
|
|
let result = self.with_doc(id, |st| {
|
|
let enabled = self.file_defaults().native_to_ascii_for(&st.path);
|
|
if st.dirty || st.uri.is_some() || st.binary || enabled == st.native_to_ascii {
|
|
return Ok(());
|
|
}
|
|
let loaded = Self::load_as(&st.path, &st.encoding, enabled)?;
|
|
self.replace_locked(id, st, &loaded.text)?;
|
|
st.native_to_ascii = enabled;
|
|
st.disk_mtime = loaded.mtime_ms;
|
|
st.disk_len = disk_len(&st.path);
|
|
Ok(())
|
|
});
|
|
if let Err(e) = result {
|
|
tracing::warn!("reload properties {id}: {e}");
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn native_to_ascii_for(&self, path: &Path) -> bool {
|
|
if let Some(id) = self.by_path(path)
|
|
&& let Ok(enabled) = self.with_doc(id, |st| Ok(st.native_to_ascii))
|
|
{
|
|
return enabled;
|
|
}
|
|
self.file_defaults().native_to_ascii_for(path)
|
|
}
|
|
|
|
fn load_as(path: &Path, encoding: &str, native_to_ascii: bool) -> IdeResult<ide_vfs::LoadedText> {
|
|
let mut loaded = if native_to_ascii {
|
|
let detected = ide_vfs::load_text(path)?;
|
|
// Converted files are ASCII even when the selected source charset is UTF-16.
|
|
if detected.text.is_ascii() && !detected.binary && detected.encoding == "utf-8" {
|
|
detected
|
|
} else {
|
|
ide_vfs::load_text_as(path, encoding)?
|
|
}
|
|
} else {
|
|
ide_vfs::load_text_as(path, encoding)?
|
|
};
|
|
if native_to_ascii {
|
|
loaded.text = ide_vfs::decode_properties_escapes(&loaded.text);
|
|
}
|
|
Ok(loaded)
|
|
}
|
|
|
|
/// Closed-file readers use the same decoding as document opening.
|
|
pub fn load_file(&self, path: &Path) -> IdeResult<ide_vfs::LoadedText> {
|
|
Self::load_with_defaults(path, &self.file_defaults())
|
|
}
|
|
|
|
pub fn load_file_as(&self, path: &Path, encoding: &str) -> IdeResult<ide_vfs::LoadedText> {
|
|
Self::load_as(path, encoding, self.native_to_ascii_for(path))
|
|
}
|
|
|
|
fn load_with_defaults(path: &Path, defaults: &FileDefaults) -> IdeResult<ide_vfs::LoadedText> {
|
|
let mut loaded = ide_vfs::load_text_with_fallback(path, defaults.fallback_for(&absolute(path)))?;
|
|
if !loaded.binary && defaults.native_to_ascii_for(path) {
|
|
loaded.text = ide_vfs::decode_properties_escapes(&loaded.text);
|
|
}
|
|
Ok(loaded)
|
|
}
|
|
|
|
pub fn file_defaults(&self) -> FileDefaults {
|
|
self.defaults.read().clone()
|
|
}
|
|
|
|
pub fn add_observer(&self, observer: std::sync::Arc<dyn DocumentObserver>) {
|
|
self.observers.write().push(observer);
|
|
}
|
|
|
|
fn observers(&self) -> Vec<Arc<dyn DocumentObserver>> {
|
|
self.observers.read().clone()
|
|
}
|
|
|
|
fn slot(&self, doc: DocId) -> IdeResult<Slot> {
|
|
self.registry
|
|
.lock()
|
|
.docs
|
|
.get(&doc)
|
|
.map(|e| e.slot.clone())
|
|
.ok_or_else(|| IdeError::not_found(format!("document {doc} is not open")))
|
|
}
|
|
|
|
/// Locks a document, failing if it was closed concurrently.
|
|
fn with_doc<T>(&self, doc: DocId, f: impl FnOnce(&mut DocState) -> IdeResult<T>) -> IdeResult<T> {
|
|
let _operation = self.operations.lock();
|
|
let slot = self.slot(doc)?;
|
|
let mut st = slot.lock();
|
|
if st.closed {
|
|
return Err(IdeError::not_found(format!("document {doc} is not open")));
|
|
}
|
|
f(&mut st)
|
|
}
|
|
|
|
/// EditorConfig resolver shared by every document of the project.
|
|
pub fn editorconfig(&self) -> &EditorConfig {
|
|
&self.editorconfig
|
|
}
|
|
|
|
fn info(id: DocId, st: &DocState) -> DocumentInfo {
|
|
Self::info_with_text(id, st, st.doc.text())
|
|
}
|
|
|
|
fn info_with_text(id: DocId, st: &DocState, text: String) -> DocumentInfo {
|
|
DocumentInfo {
|
|
id,
|
|
path: st.path.clone().into(),
|
|
text,
|
|
version: st.doc.version(),
|
|
language: st.language.clone(),
|
|
encoding: st.encoding.clone(),
|
|
line_ending: st.doc.line_ending(),
|
|
read_only: st.read_only,
|
|
dirty: st.dirty,
|
|
large: st.large,
|
|
editorconfig: st.editorconfig.clone(),
|
|
uri: st.uri.clone(),
|
|
ui_mode: st.ui_mode.clone(),
|
|
}
|
|
}
|
|
|
|
fn lookup(reg: &Registry, path: &Path) -> Option<DocId> {
|
|
if let Some(id) = reg.by_key.get(path) {
|
|
return Some(*id);
|
|
}
|
|
if reg.docs.is_empty() {
|
|
return None;
|
|
}
|
|
dunce::canonicalize(path).ok().and_then(|c| reg.by_key.get(&c).copied())
|
|
}
|
|
|
|
/// Opens (or re-references) the document at `path`.
|
|
pub fn open(&self, path: &Path) -> IdeResult<DocumentInfo> {
|
|
let _operation = self.operations.lock();
|
|
if let Some(info) = self.reopen(path) {
|
|
return Ok(info);
|
|
}
|
|
let defaults = self.file_defaults();
|
|
let loaded = Self::load_with_defaults(path, &defaults)?;
|
|
let language = (self.detect_language)(path, loaded.text.lines().next());
|
|
let ui_mode = (self.detect_ui_mode)(path, &language);
|
|
// Archive entries (`<jar>!/<entry>`) are read-only and kept from language
|
|
// servers and Local History, which only know real files.
|
|
let in_archive = ide_vfs::is_archive_path(path);
|
|
let meta = if in_archive { None } else { std::fs::metadata(path).ok() };
|
|
let writable = !in_archive && meta.as_ref().map(|m| !m.permissions().readonly()).unwrap_or(true);
|
|
let empty_file = meta.as_ref().is_some_and(|m| m.len() == 0);
|
|
let observed = !(loaded.binary || loaded.too_large || in_archive);
|
|
let editorconfig = if loaded.binary || in_archive || !defaults.editorconfig {
|
|
None
|
|
} else {
|
|
self.editorconfig.resolve(&absolute(path))
|
|
};
|
|
let mut doc = Document::new(&loaded.text);
|
|
let (mut encoding, mut has_bom) = (loaded.encoding, loaded.has_bom);
|
|
// An empty file has nothing to detect: Code Style / File Encodings defaults apply.
|
|
if empty_file && !loaded.binary {
|
|
doc.set_line_ending(defaults.line_ending);
|
|
encoding = defaults.encoding.clone();
|
|
has_bom = defaults.encoding == "utf-8" && defaults.utf8_bom;
|
|
}
|
|
if let Some(ec) = &editorconfig {
|
|
if let Some(le) = ec.end_of_line {
|
|
doc.set_line_ending(le);
|
|
}
|
|
// An empty file has no detectable encoding: take the configured one.
|
|
if empty_file && let Some((enc, bom)) = ec.encoding() {
|
|
(encoding, has_bom) = (enc.to_owned(), bom);
|
|
}
|
|
}
|
|
// The loaded text doubles as the IPC copy when normalization had nothing
|
|
// to do, so a huge file is held at most twice (rope + response).
|
|
let ipc_text = (!loaded.text.contains('\r')).then_some(loaded.text);
|
|
let state = DocState {
|
|
path: path.to_path_buf(),
|
|
doc,
|
|
language,
|
|
ui_mode,
|
|
encoding,
|
|
has_bom,
|
|
native_to_ascii: !loaded.binary && defaults.native_to_ascii_for(path),
|
|
read_only: !observed || !writable,
|
|
large: loaded.large,
|
|
editorconfig,
|
|
observed,
|
|
binary: loaded.binary,
|
|
dirty: false,
|
|
disk_mtime: loaded.mtime_ms,
|
|
disk_len: disk_len(path),
|
|
closed: false,
|
|
uri: None,
|
|
};
|
|
let slot: Slot = Arc::new(Mutex::new(state));
|
|
let st = slot.lock();
|
|
let id = {
|
|
let mut reg = self.registry.lock();
|
|
if Self::lookup(®, path).is_some() {
|
|
// Lost a race with a concurrent open of the same file.
|
|
drop(reg);
|
|
drop(st);
|
|
return self.reopen(path).ok_or_else(|| IdeError::internal("document vanished while opening"));
|
|
}
|
|
let id = DocId::next();
|
|
let keys = keys_for(path);
|
|
for k in &keys {
|
|
reg.by_key.insert(k.clone(), id);
|
|
}
|
|
reg.docs.insert(id, RegEntry { slot: slot.clone(), refs: 1, keys });
|
|
id
|
|
};
|
|
let info = match ipc_text {
|
|
Some(text) => Self::info_with_text(id, &st, text),
|
|
None => Self::info(id, &st),
|
|
};
|
|
if st.observed {
|
|
for o in self.observers() {
|
|
o.opened(&info);
|
|
}
|
|
}
|
|
Ok(info)
|
|
}
|
|
|
|
/// Opens (or re-references) a read-only virtual document: contents a
|
|
/// language server provides for a non-`file:` URI. It is keyed by the URI,
|
|
/// never touches the disk and cannot be edited or saved.
|
|
pub fn open_virtual(&self, uri: &str, language: LanguageId, text: &str) -> IdeResult<DocumentInfo> {
|
|
let _operation = self.operations.lock();
|
|
let key = PathBuf::from(uri);
|
|
if let Some(info) = self.reopen(&key) {
|
|
return Ok(info);
|
|
}
|
|
let large = text.len() as u64 > ide_vfs::LARGE_FILE_THRESHOLD;
|
|
let state = DocState {
|
|
path: key.clone(),
|
|
doc: Document::new(text),
|
|
language,
|
|
ui_mode: None,
|
|
encoding: "utf-8".into(),
|
|
has_bom: false,
|
|
native_to_ascii: false,
|
|
read_only: true,
|
|
large,
|
|
editorconfig: None,
|
|
observed: true,
|
|
binary: false,
|
|
dirty: false,
|
|
disk_mtime: 0.0,
|
|
disk_len: 0,
|
|
closed: false,
|
|
uri: Some(uri.to_owned()),
|
|
};
|
|
let slot: Slot = Arc::new(Mutex::new(state));
|
|
let st = slot.lock();
|
|
let id = {
|
|
let mut reg = self.registry.lock();
|
|
if reg.by_key.contains_key(&key) {
|
|
drop(reg);
|
|
drop(st);
|
|
return self.reopen(&key).ok_or_else(|| IdeError::internal("document vanished while opening"));
|
|
}
|
|
let id = DocId::next();
|
|
reg.by_key.insert(key.clone(), id);
|
|
reg.docs.insert(id, RegEntry { slot: slot.clone(), refs: 1, keys: vec![key] });
|
|
id
|
|
};
|
|
let info = Self::info(id, &st);
|
|
for o in self.observers() {
|
|
o.opened(&info);
|
|
}
|
|
Ok(info)
|
|
}
|
|
|
|
fn reopen(&self, path: &Path) -> Option<DocumentInfo> {
|
|
let slot = {
|
|
let mut reg = self.registry.lock();
|
|
let id = Self::lookup(®, path)?;
|
|
let entry = reg.docs.get_mut(&id)?;
|
|
entry.refs += 1;
|
|
(id, entry.slot.clone())
|
|
};
|
|
let st = slot.1.lock();
|
|
Some(Self::info(slot.0, &st))
|
|
}
|
|
|
|
pub fn close(&self, doc: DocId) {
|
|
let _operation = self.operations.lock();
|
|
let slot = {
|
|
let mut reg = self.registry.lock();
|
|
let Some(entry) = reg.docs.get_mut(&doc) else { return };
|
|
entry.refs = entry.refs.saturating_sub(1);
|
|
if entry.refs > 0 {
|
|
return;
|
|
}
|
|
let entry = reg.docs.remove(&doc).expect("present");
|
|
for k in &entry.keys {
|
|
if reg.by_key.get(k) == Some(&doc) {
|
|
reg.by_key.remove(k);
|
|
}
|
|
}
|
|
entry.slot
|
|
};
|
|
let mut st = slot.lock();
|
|
st.closed = true;
|
|
if st.observed {
|
|
for o in self.observers() {
|
|
o.closed(doc, &st.path);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn set_dirty(&self, doc: DocId, st: &mut DocState, dirty: bool) {
|
|
if st.dirty != dirty {
|
|
st.dirty = dirty;
|
|
self.sink.emit(IdeEvent::DocDirty { doc, dirty });
|
|
}
|
|
}
|
|
|
|
fn notify_changed(&self, doc: DocId, st: &DocState, applied: &[AppliedChange], edits: &[ByteEdit]) {
|
|
if !st.observed {
|
|
return;
|
|
}
|
|
let full_text = || st.doc.text();
|
|
for o in self.observers() {
|
|
o.changed(doc, &st.path, st.doc.version(), applied, &full_text);
|
|
o.edited(doc, st.doc.version(), edits);
|
|
}
|
|
}
|
|
|
|
/// Applies an edit from the UI. Returns the new version.
|
|
pub fn edit(&self, doc: DocId, base_version: u32, changes: ChangeSet) -> IdeResult<u32> {
|
|
self.with_doc(doc, |st| {
|
|
if st.read_only {
|
|
tracing::warn!("edit rejected: {} is read-only", st.path.display());
|
|
return Err(read_only_error(&st.path));
|
|
}
|
|
let (applied, edits) = st.doc.apply_detailed(base_version, &changes).inspect_err(|e| {
|
|
// A stale version is routine (the UI rebases); anything else makes the editor drop the edit.
|
|
if e.kind != ErrorKind::OutOfSync {
|
|
tracing::warn!("edit of {} rejected: {e}", st.path.display());
|
|
}
|
|
})?;
|
|
self.set_dirty(doc, st, true);
|
|
self.notify_changed(doc, st, &applied, &edits);
|
|
Ok(st.doc.version())
|
|
})
|
|
}
|
|
|
|
/// Applies a backend-originated edit and emits `IdeEvent::DocPatched`.
|
|
pub fn apply_edits(&self, doc: DocId, edits: &[TextEdit]) -> IdeResult<u32> {
|
|
self.with_doc(doc, |st| {
|
|
if st.read_only {
|
|
return Err(read_only_error(&st.path));
|
|
}
|
|
if edits.is_empty() {
|
|
return Ok(st.doc.version());
|
|
}
|
|
let base_version = st.doc.version();
|
|
let (set, applied, bytes) = st.doc.apply_edits_detailed(edits)?;
|
|
self.patched(doc, st, base_version, set, &applied, &bytes);
|
|
self.set_dirty(doc, st, true);
|
|
Ok(st.doc.version())
|
|
})
|
|
}
|
|
|
|
/// [`apply_edits`](Self::apply_edits) only if the document is still at
|
|
/// `version` (checked under the document's lock). `Ok(None)` when it
|
|
/// changed since; nothing is applied then.
|
|
pub fn apply_edits_at(&self, doc: DocId, version: u32, edits: &[TextEdit]) -> IdeResult<Option<u32>> {
|
|
self.with_doc(doc, |st| {
|
|
if st.doc.version() != version {
|
|
return Ok(None);
|
|
}
|
|
if st.read_only {
|
|
return Err(read_only_error(&st.path));
|
|
}
|
|
if edits.is_empty() {
|
|
return Ok(Some(version));
|
|
}
|
|
let (set, applied, bytes) = st.doc.apply_edits_detailed(edits)?;
|
|
self.patched(doc, st, version, set, &applied, &bytes);
|
|
self.set_dirty(doc, st, true);
|
|
Ok(Some(st.doc.version()))
|
|
})
|
|
}
|
|
|
|
fn patched(
|
|
&self,
|
|
doc: DocId,
|
|
st: &DocState,
|
|
base_version: u32,
|
|
set: ChangeSet,
|
|
applied: &[AppliedChange],
|
|
edits: &[ByteEdit],
|
|
) {
|
|
self.notify_changed(doc, st, applied, edits);
|
|
self.sink.emit(IdeEvent::DocPatched {
|
|
doc,
|
|
base_version,
|
|
version: st.doc.version(),
|
|
changes: set.changes,
|
|
workspace_edit: self.workspace_edit_active.load(std::sync::atomic::Ordering::Relaxed),
|
|
});
|
|
}
|
|
|
|
/// Minimal-diff replacement; no event when the text is unchanged.
|
|
fn replace_locked(&self, doc: DocId, st: &mut DocState, text: &str) -> IdeResult<bool> {
|
|
let set = st.doc.diff(text);
|
|
if set.changes.is_empty() {
|
|
return Ok(false);
|
|
}
|
|
let base_version = st.doc.version();
|
|
let (applied, edits) = st.doc.apply_detailed(base_version, &set)?;
|
|
self.patched(doc, st, base_version, set, &applied, &edits);
|
|
Ok(true)
|
|
}
|
|
|
|
/// Replaces the whole text (minimal diff) and emits `IdeEvent::DocPatched`.
|
|
pub fn replace_all(&self, doc: DocId, text: &str) -> IdeResult<u32> {
|
|
self.with_doc(doc, |st| {
|
|
if st.read_only {
|
|
return Err(read_only_error(&st.path));
|
|
}
|
|
if self.replace_locked(doc, st, text)? {
|
|
self.set_dirty(doc, st, true);
|
|
}
|
|
Ok(st.doc.version())
|
|
})
|
|
}
|
|
|
|
/// Writes the document with its original encoding, BOM and line ending.
|
|
/// A clean document is not rewritten.
|
|
pub fn save(&self, doc: DocId) -> IdeResult<()> {
|
|
self.with_doc(doc, |st| {
|
|
if !st.dirty || st.uri.is_some() {
|
|
return Ok(());
|
|
}
|
|
let text = st.doc.text_for_save();
|
|
let mtime = if st.native_to_ascii {
|
|
ide_vfs::save_properties_text(&st.path, &text)?
|
|
} else {
|
|
ide_vfs::save_text(&st.path, &text, &st.encoding, st.has_bom)?
|
|
};
|
|
st.disk_mtime = mtime;
|
|
st.disk_len = disk_len(&st.path);
|
|
self.set_dirty(doc, st, false);
|
|
if st.observed {
|
|
let text = st.doc.text();
|
|
for o in self.observers() {
|
|
o.saved(doc, &st.path, &text);
|
|
}
|
|
}
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
/// Saves every dirty document; reports the first failure after trying all.
|
|
pub fn save_all(&self) -> IdeResult<()> {
|
|
let ids: Vec<DocId> = self.registry.lock().docs.keys().copied().collect();
|
|
let mut first_err = None;
|
|
for id in ids {
|
|
match self.save(id) {
|
|
Ok(()) => {}
|
|
Err(e) if e.kind == ErrorKind::NotFound && self.slot(id).is_err() => {} // closed meanwhile
|
|
Err(e) => {
|
|
tracing::warn!("save {id}: {e}");
|
|
first_err.get_or_insert(e);
|
|
}
|
|
}
|
|
}
|
|
first_err.map_or(Ok(()), Err)
|
|
}
|
|
|
|
/// Full snapshot for resync after `OutOfSync`.
|
|
pub fn snapshot(&self, doc: DocId) -> IdeResult<DocumentInfo> {
|
|
self.with_doc(doc, |st| Ok(Self::info(doc, st)))
|
|
}
|
|
|
|
/// Text, version and language only (cheaper than [`Documents::snapshot`]).
|
|
pub fn text_snapshot(&self, doc: DocId) -> IdeResult<TextSnapshot> {
|
|
self.with_doc(doc, |st| {
|
|
Ok(TextSnapshot {
|
|
version: st.doc.version(),
|
|
language: st.language.clone(),
|
|
large: st.large,
|
|
text: st.doc.text(),
|
|
})
|
|
})
|
|
}
|
|
|
|
pub fn by_path(&self, path: &Path) -> Option<DocId> {
|
|
Self::lookup(&self.registry.lock(), path)
|
|
}
|
|
|
|
/// Current text of an open document; `None` if not open (callers read disk).
|
|
pub fn text_of(&self, path: &Path) -> Option<String> {
|
|
let id = self.by_path(path)?;
|
|
self.with_doc(id, |st| Ok(st.doc.text())).ok()
|
|
}
|
|
|
|
/// Search overlays include closed properties files when their disk spelling
|
|
/// differs from the text that opening the document would display.
|
|
pub fn search_text_of(&self, path: &Path) -> Option<String> {
|
|
self.text_of(path).or_else(|| {
|
|
if !self.native_to_ascii_for(path) {
|
|
return None;
|
|
}
|
|
let loaded = self.load_file(path).ok()?;
|
|
(!loaded.binary && !loaded.too_large).then(|| ide_text::normalize_line_endings(&loaded.text).into_owned())
|
|
})
|
|
}
|
|
|
|
/// Toggle Read-only Attribute: sets the file's writable bit and, when the
|
|
/// file is open, whether its document accepts edits. Returns the resulting
|
|
/// read-only state.
|
|
pub fn set_writable(&self, path: &Path, writable: bool) -> IdeResult<bool> {
|
|
let open = self.by_path(path);
|
|
if let Some(id) = open
|
|
&& self.with_doc(id, |st| Ok(st.uri.is_some()))?
|
|
{
|
|
return Err(IdeError::invalid("library documents are always read-only"));
|
|
}
|
|
ide_vfs::set_writable(path, writable)?;
|
|
match open {
|
|
Some(id) => self.with_doc(id, |st| {
|
|
st.read_only = !st.observed || !writable;
|
|
Ok(st.read_only)
|
|
}),
|
|
None => Ok(!writable),
|
|
}
|
|
}
|
|
|
|
/// Whether the document has unsaved changes.
|
|
pub fn is_dirty(&self, doc: DocId) -> IdeResult<bool> {
|
|
self.with_doc(doc, |st| Ok(st.dirty))
|
|
}
|
|
|
|
/// Path and version for navigation queries, without copying document text.
|
|
pub fn identity(&self, doc: DocId) -> IdeResult<(PathBuf, u32)> {
|
|
self.with_doc(doc, |st| Ok((st.path.clone(), st.doc.version())))
|
|
}
|
|
|
|
/// Current generation without copying the document text.
|
|
pub fn version(&self, doc: DocId) -> IdeResult<u32> {
|
|
self.with_doc(doc, |st| Ok(st.doc.version()))
|
|
}
|
|
|
|
/// Ids of all open documents.
|
|
pub fn open_documents(&self) -> Vec<DocId> {
|
|
self.registry.lock().docs.keys().copied().collect()
|
|
}
|
|
|
|
/// Detects the language and UI mode of every open file again (file type
|
|
/// associations changed, a framework's root marker appeared or went). A
|
|
/// document whose language or mode changes is reported as
|
|
/// [`IdeEvent::DocLanguage`]; one whose language changes is also reopened
|
|
/// for the observers, so it moves to the matching language servers and
|
|
/// syntax tree. Returns the changed documents.
|
|
pub fn redetect_languages(&self) -> Vec<DocId> {
|
|
let _operation = self.operations.lock();
|
|
let slots: Vec<(DocId, Slot)> = self.registry.lock().docs.iter().map(|(id, e)| (*id, e.slot.clone())).collect();
|
|
let mut changed = Vec::new();
|
|
for (id, slot) in slots {
|
|
let mut st = slot.lock();
|
|
if st.closed || st.uri.is_some() {
|
|
continue;
|
|
}
|
|
let text = st.doc.text();
|
|
let language = (self.detect_language)(&st.path, text.lines().next());
|
|
let ui_mode = (self.detect_ui_mode)(&st.path, &language);
|
|
if language == st.language && ui_mode == st.ui_mode {
|
|
continue;
|
|
}
|
|
st.ui_mode = ui_mode;
|
|
if language != st.language {
|
|
st.language = language.clone();
|
|
if st.observed {
|
|
let info = Self::info_with_text(id, &st, text);
|
|
for o in self.observers() {
|
|
o.closed(id, &st.path);
|
|
o.opened(&info);
|
|
}
|
|
}
|
|
}
|
|
self.sink.emit(IdeEvent::DocLanguage { doc: id, language, ui_mode: st.ui_mode.clone() });
|
|
changed.push(id);
|
|
}
|
|
changed
|
|
}
|
|
|
|
/// Changes the line ending used on save; marks the document dirty.
|
|
pub fn set_line_ending(&self, doc: DocId, le: LineEnding) -> IdeResult<()> {
|
|
self.with_doc(doc, |st| {
|
|
if st.doc.line_ending() == le {
|
|
return Ok(());
|
|
}
|
|
if st.read_only {
|
|
return Err(read_only_error(&st.path));
|
|
}
|
|
st.doc.set_line_ending(le);
|
|
self.set_dirty(doc, st, true);
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
/// Changes the document's encoding. `reload`: re-decode the file on disk with
|
|
/// it (refused while there are unsaved changes); otherwise convert: the text
|
|
/// is kept and the next save writes it in the new encoding.
|
|
pub fn set_encoding(&self, doc: DocId, encoding: &str, reload: bool) -> IdeResult<String> {
|
|
self.with_doc(doc, |st| {
|
|
if st.uri.is_some() {
|
|
return Err(read_only_error(&st.path));
|
|
}
|
|
ide_vfs::encode("", encoding, false)?;
|
|
if reload {
|
|
if st.dirty {
|
|
return Err(IdeError::invalid("save or revert the unsaved changes before reloading"));
|
|
}
|
|
let enabled = self.file_defaults().native_to_ascii_for(&st.path);
|
|
let loaded = Self::load_as(&st.path, encoding, enabled)?;
|
|
st.native_to_ascii = enabled;
|
|
st.encoding = loaded.encoding.clone();
|
|
st.has_bom = loaded.has_bom;
|
|
st.disk_mtime = loaded.mtime_ms;
|
|
st.disk_len = disk_len(&st.path);
|
|
self.replace_locked(doc, st, &loaded.text)?;
|
|
// Reloading is not an edit: the buffer matches the file again.
|
|
self.set_dirty(doc, st, false);
|
|
return Ok(loaded.encoding);
|
|
}
|
|
if st.read_only {
|
|
return Err(read_only_error(&st.path));
|
|
}
|
|
let utf16 = encoding.starts_with("utf-16");
|
|
let has_bom = utf16 || (st.has_bom && encoding == "utf-8");
|
|
// Fails (with the offending character) when the text is not representable.
|
|
if !st.native_to_ascii {
|
|
ide_vfs::encode(&st.doc.text_for_save(), encoding, has_bom)?;
|
|
}
|
|
st.encoding = encoding.to_owned();
|
|
st.has_bom = has_bom;
|
|
self.set_dirty(doc, st, true);
|
|
Ok(st.encoding.clone())
|
|
})
|
|
}
|
|
|
|
/// Re-checks open file buffers, including files outside the watched roots.
|
|
/// Call from a blocking worker; unchanged files only need their metadata.
|
|
pub fn sync_from_disk(&self) {
|
|
self.sync_open_documents(false);
|
|
}
|
|
|
|
/// Refreshes clean buffers after an IDE operation rewrites files. Such an
|
|
/// operation can preserve both file length and the timestamp's millisecond.
|
|
/// The open document's selected codec is retained.
|
|
pub fn sync_after_disk_change(&self) {
|
|
self.sync_open_documents(true);
|
|
}
|
|
|
|
fn sync_open_documents(&self, force_clean: bool) {
|
|
let slots: Vec<Slot> = self.registry.lock().docs.values().map(|e| e.slot.clone()).collect();
|
|
let paths: Vec<PathBuf> = slots
|
|
.iter()
|
|
.filter_map(|slot| {
|
|
let st = slot.lock();
|
|
(!st.closed && st.uri.is_none() && !st.binary && !ide_vfs::is_archive_path(&st.path))
|
|
.then(|| st.path.clone())
|
|
})
|
|
.collect();
|
|
self.reload_from_disk(&paths, force_clean, true);
|
|
}
|
|
|
|
/// Re-reads from disk after an external change. Clean documents reload
|
|
/// silently; dirty ones are left alone and reported via `IdeEvent::Notify`.
|
|
/// Changes matching our own last save are ignored.
|
|
pub fn on_disk_changed(&self, paths: &[PathBuf]) {
|
|
self.reload_from_disk(paths, false, false);
|
|
}
|
|
|
|
/// `dedup`: blind scans skip files whose `(mtime, length)` stamp still matches.
|
|
fn reload_from_disk(&self, paths: &[PathBuf], force_clean: bool, dedup: bool) {
|
|
for path in paths {
|
|
let Some(id) = self.by_path(path) else { continue };
|
|
let result = self.with_doc(id, |st| {
|
|
if st.uri.is_some() || st.binary || ide_vfs::is_archive_path(&st.path) {
|
|
return Ok(());
|
|
}
|
|
let Ok(meta) = std::fs::metadata(&st.path) else { return Ok(()) };
|
|
let mtime = ide_vfs::mtime_ms(&meta);
|
|
let unchanged = mtime == st.disk_mtime && meta.len() == st.disk_len;
|
|
// Blind scans trust the stamp. OS events are authoritative, but they also
|
|
// echo our own saves: a clean document whose stamp matches is re-checked by
|
|
// bytes — an external write that preserved both length and mtime differs
|
|
// from what saving this buffer would write, our own save's echo does not.
|
|
// Native-to-ascii properties documents and dirty documents keep the stamp
|
|
// dedup: the former's disk bytes legitimately differ from the buffer text,
|
|
// the latter warn once per disk state.
|
|
if unchanged
|
|
&& (!force_clean || st.dirty)
|
|
&& (dedup
|
|
|| st.dirty
|
|
|| st.native_to_ascii
|
|
|| ide_vfs::encode(&st.doc.text_for_save(), &st.encoding, st.has_bom)
|
|
.map(|bytes| bytes == std::fs::read(&st.path).unwrap_or_default())
|
|
.unwrap_or(true))
|
|
{
|
|
return Ok(());
|
|
}
|
|
if st.dirty {
|
|
st.disk_mtime = mtime;
|
|
st.disk_len = meta.len();
|
|
let name = st.path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
|
self.sink.emit(IdeEvent::Notify {
|
|
level: NotifyLevel::Warning,
|
|
title: format!("{name} changed on disk"),
|
|
body: Some(format!(
|
|
"{} was modified externally but has unsaved changes in the editor. \
|
|
Saving will overwrite the external changes.",
|
|
st.path.display()
|
|
)),
|
|
});
|
|
return Ok(());
|
|
}
|
|
let loaded = if force_clean {
|
|
Self::load_as(&st.path, &st.encoding, st.native_to_ascii)?
|
|
} else {
|
|
let defaults = self.file_defaults();
|
|
let loaded = Self::load_with_defaults(&st.path, &defaults)?;
|
|
st.native_to_ascii = defaults.native_to_ascii_for(&st.path);
|
|
loaded
|
|
};
|
|
st.disk_mtime = loaded.mtime_ms;
|
|
st.disk_len = disk_len(&st.path);
|
|
if loaded.binary || st.binary {
|
|
return Ok(());
|
|
}
|
|
st.encoding = loaded.encoding;
|
|
st.has_bom = loaded.has_bom;
|
|
st.large = loaded.large;
|
|
let ec_eol = st.editorconfig.as_ref().and_then(|ec| ec.end_of_line);
|
|
st.doc.set_line_ending(ec_eol.unwrap_or_else(|| LineEnding::detect(&loaded.text)));
|
|
self.replace_locked(id, st, &loaded.text)?;
|
|
Ok(())
|
|
});
|
|
if let Err(e) = result {
|
|
tracing::warn!("reload {}: {e}", path.display());
|
|
}
|
|
}
|
|
}
|
|
}
|