use std::collections::VecDeque;
use strop_core::id::{BufferRevision, DocumentId};
use strop_lsp::{PositionEncoding, ServerEdit, ServerPosition};
use strop_workspace::ResourceLocation;
use super::Editor;
#[derive(Debug, Clone, Copy)]
pub(crate) enum ChangeProducer {
Format,
Rename,
CodeAction,
CollectionEdit,
}
impl ChangeProducer {
fn label(&self) -> &'static str {
match self {
Self::Format => "format",
Self::Rename => "rename",
Self::CodeAction => "code action",
Self::CollectionEdit => "collection edit",
}
}
}
#[derive(Debug)]
pub(crate) struct PlannedDocument {
pub location: ResourceLocation,
pub document: DocumentId,
pub base: BufferRevision,
pub edits: Vec<strop_core::Replacement>,
}
#[derive(Debug)]
pub(crate) struct ChangeReceipt {
pub producer: String,
pub applied: Vec<(DocumentId, BufferRevision, BufferRevision)>,
pub refused: Vec<(ResourceLocation, String)>,
}
#[derive(Debug)]
pub(crate) struct ChangePlan {
pub producer: ChangeProducer,
pub documents: Vec<PlannedDocument>,
pub refused: Vec<(ResourceLocation, String)>,
}
pub(crate) struct ChangeState {
receipts: VecDeque<ChangeReceipt>,
pub(crate) pending_actions: Vec<strop_lsp::ProtoAction>,
pub(crate) pending_encoding: strop_lsp::PositionEncoding,
}
impl Default for ChangeState {
fn default() -> Self {
Self {
receipts: VecDeque::new(),
pending_actions: Vec::new(),
pending_encoding: strop_lsp::PositionEncoding::Utf16,
}
}
}
impl ChangeState {
const RETAINED: usize = 16;
fn record(&mut self, receipt: ChangeReceipt) {
self.receipts.push_back(receipt);
while self.receipts.len() > Self::RETAINED {
self.receipts.pop_front();
}
}
}
#[cfg(test)]
mod tests;
impl Editor {
pub(crate) fn accept_code_action(&mut self, index: usize) {
let Some(action) = self.changes.pending_actions.get(index) else {
self.message = "stale code action — re-request".into();
return;
};
let Some(edits) = action.edits.clone() else {
self.message = if action.has_external_command {
"action runs an external command — not applicable in strop".into()
} else {
"action needs file operations strop does not apply yet".into()
};
return;
};
if edits.is_empty() {
self.message = "action has no edits".into();
return;
}
let encoding = self.changes.pending_encoding;
let plan = self.build_change_plan(ChangeProducer::CodeAction, edits, encoding);
self.apply_change_plan(plan);
}
}
impl Editor {
fn bound_document(&self, location: &ResourceLocation) -> Option<DocumentId> {
self.lsp_state
.bindings
.iter()
.find(|(_, binding)| {
binding.path == location.path && binding.target == location.filesystem
})
.map(|(document, _)| *document)
}
fn resolve_edit(
&self,
document: DocumentId,
edit: &ServerEdit,
encoding: PositionEncoding,
) -> Option<strop_core::Replacement> {
let buf = &self.docs.get(document)?.buf;
let offset = |position: &ServerPosition| {
let last = buf.len_lines().saturating_sub(1);
let line = position.line.get().min(last);
let line_start = buf.line_start(line);
let text = buf.line_text(strop_core::id::LineIndex::new(line));
let col = strop_lsp::to_byte_col(&text, position.column, encoding);
line_start + col.get()
};
let (start, end) = (offset(&edit.start), offset(&edit.end));
if start > end {
return None;
}
Some(strop_core::Replacement::new(
strop_core::Range::charwise(start, end),
edit.new_text.clone(),
))
}
pub(crate) fn build_change_plan(
&self,
producer: ChangeProducer,
edits: Vec<(ResourceLocation, Vec<ServerEdit>)>,
encoding: PositionEncoding,
) -> ChangePlan {
let mut documents = Vec::new();
let mut refused = Vec::new();
for (location, server_edits) in edits {
let total = server_edits.len();
let Some(document) = self.bound_document(&location) else {
refused.push((
location,
format!("{total} edit(s) target a document not open on the server"),
));
continue;
};
let Some(doc) = self.docs.get(document) else {
refused.push((location, "the bound document is closed".into()));
continue;
};
let base = doc.buf.revision();
let mut resolved = Vec::with_capacity(total);
let mut invalid = 0;
for edit in &server_edits {
match self.resolve_edit(document, edit, encoding) {
Some(replacement) => resolved.push(replacement),
None => invalid += 1,
}
}
if invalid > 0 {
refused.push((
location,
format!("{invalid} of {total} edit(s) resolve outside the document"),
));
continue;
}
if resolved.is_empty() {
continue; }
resolved.sort_by_key(|replacement| replacement.range.start.get());
documents.push(PlannedDocument {
location,
document,
base,
edits: resolved,
});
}
ChangePlan {
producer,
documents,
refused,
}
}
pub(crate) fn apply_change_plan(&mut self, plan: ChangePlan) {
let producer = plan.producer.label().to_string();
let mut receipt = ChangeReceipt {
producer: producer.clone(),
applied: Vec::new(),
refused: plan.refused,
};
for target in plan.documents {
let changes = super::transact::ChangeSet {
edits: target.edits,
undo_open: true,
};
match self.apply(target.document, target.base, changes) {
Ok(committed) => {
receipt
.applied
.push((target.document, target.base, committed.revision))
}
Err(error) => receipt
.refused
.push((target.location, format!("changed since plan: {error}"))),
}
}
self.message = match (receipt.applied.len(), receipt.refused.len()) {
(applied, 0) => format!("{producer}: applied to {applied} buffer(s)"),
(applied, refused) => {
format!("{producer}: {applied} buffer(s) applied, {refused} target(s) refused")
}
};
self.changes.record(receipt);
}
pub(crate) fn undo_last_change(&mut self) {
let Some(receipt) = self.changes.receipts.pop_back() else {
self.message = "no change to undo".into();
return;
};
let mut undone = 0;
let mut skipped = 0;
for (document, _before, after) in &receipt.applied {
let Some(doc) = self.docs.get(*document) else {
skipped += 1;
continue;
};
if doc.buf.revision() != *after {
skipped += 1;
continue;
}
match self.doc_mut(*document).buf.undo() {
Ok(Some(_)) => undone += 1,
_ => skipped += 1,
}
}
self.message = match skipped {
0 => format!("undid {} across {undone} buffer(s)", receipt.producer),
_ => format!(
"undid {} in {undone} buffer(s); {skipped} skipped (edited or closed since)",
receipt.producer
),
};
}
}