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,
Replace,
}
impl ChangeProducer {
fn label(&self) -> &'static str {
match self {
Self::Format => "format",
Self::Rename => "rename",
Self::CodeAction => "code action",
Self::Replace => "replace",
}
}
}
#[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 applied_positions: Vec<usize>,
pub refused: Vec<(ResourceLocation, String)>,
pub redo_positions: Option<Vec<usize>>,
}
#[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>,
undone: 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(),
undone: VecDeque::new(),
pending_actions: Vec::new(),
pending_encoding: strop_lsp::PositionEncoding::Utf16,
}
}
}
impl ChangeState {
const RETAINED: usize = 16;
pub(crate) fn record(&mut self, receipt: ChangeReceipt) {
debug_assert_eq!(receipt.applied.len(), receipt.applied_positions.len());
self.undone.clear(); self.receipts.push_back(receipt);
while self.receipts.len() > Self::RETAINED {
self.receipts.pop_front();
}
}
pub(crate) fn take_newest_matching(
&mut self,
pred: impl Fn(&ChangeReceipt) -> bool,
) -> Option<(usize, ChangeReceipt)> {
let index = self.receipts.iter().rposition(pred)?;
self.receipts.remove(index).map(|receipt| (index, receipt))
}
pub(crate) fn restore(&mut self, index: usize, receipt: ChangeReceipt) {
self.receipts
.insert(index.min(self.receipts.len()), receipt);
}
pub(crate) fn take_undone_matching(
&mut self,
pred: impl Fn(&ChangeReceipt) -> bool,
) -> Option<ChangeReceipt> {
let index = self.undone.iter().rposition(pred)?;
self.undone.remove(index)
}
pub(crate) fn push_undone(&mut self, receipt: ChangeReceipt) {
self.undone.push_back(receipt);
}
pub(crate) fn push_receipt_back(&mut self, receipt: ChangeReceipt) {
self.receipts.push_back(receipt);
}
}
pub(crate) mod review;
#[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.present_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(),
applied_positions: Vec::new(),
refused: plan.refused,
redo_positions: None,
};
for target in plan.documents {
let changes = super::transact::ChangeSet {
edits: target.edits,
undo_open: false,
};
match self.apply(target.document, target.base, changes) {
Ok(committed) => {
receipt
.applied
.push((target.document, target.base, committed.revision));
if let Some(position) = self
.docs
.get(target.document)
.and_then(|doc| doc.buf.history().committed_position())
{
receipt.applied_positions.push(position);
}
}
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(mut receipt) = self.changes.receipts.pop_back() else {
self.message = "no change to undo".into();
return;
};
let mut undone = 0;
let mut remaining = Vec::new();
let mut positions = Vec::new();
for (member, &(document, before, after)) in receipt.applied.iter().enumerate() {
let expected = receipt.applied_positions[member];
let ready = self.docs.get(document).is_some_and(|doc| {
!doc.buf.readonly && doc.buf.history().committed_position() == Some(expected)
});
if ready && matches!(self.doc_mut(document).buf.undo(), Ok(Some(_))) {
undone += 1;
} else {
remaining.push((document, before, after));
positions.push(expected);
}
}
let skipped = remaining.len();
self.message = if skipped == 0 {
format!("undid {} across {undone} buffer(s)", receipt.producer)
} else {
format!("undid {} in {undone} buffer(s); {skipped} skipped (edited, read-only or closed); receipt retained", receipt.producer)
};
if skipped > 0 {
receipt.applied = remaining;
receipt.applied_positions = positions;
self.changes.receipts.push_back(receipt);
}
}
}