#![allow(dead_code)]
use std::borrow::Cow;
use std::ops;
use std::path::Path;
use ropey::Rope;
use crate::{Error, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action<'a> {
Delete(Option<usize>, Option<usize>),
Insert(usize, Cow<'a, str>),
Replace(Option<usize>, Option<usize>, Cow<'a, str>),
}
impl<'a> Action<'a> {
pub fn delete<R: ops::RangeBounds<usize>>(range: R) -> Self {
let start = index_bound_to_option(range.start_bound().cloned());
let end = index_bound_to_option(range.end_bound().cloned());
Self::Delete(start, end)
}
pub fn insert(idx: usize, text: impl Into<Cow<'a, str>>) -> Self {
Self::Insert(idx, text.into())
}
pub fn replace<R: ops::RangeBounds<usize>>(range: R, text: impl Into<Cow<'a, str>>) -> Self {
let start = index_bound_to_option(range.start_bound().cloned());
let end = index_bound_to_option(range.end_bound().cloned());
Self::Replace(start, end, text.into())
}
}
fn index_bound_to_option(bound: ops::Bound<usize>) -> Option<usize> {
match bound {
ops::Bound::Included(idx) => Some(idx),
ops::Bound::Excluded(idx) => Some(idx - 1),
ops::Bound::Unbounded => None,
}
}
pub fn edit_file(path: &Path, actions: &[Action]) -> Result<()> {
use std::fs::File;
use std::io::{BufReader, BufWriter};
let open = |err| Error::CouldNotOpenFile {
path: path.to_path_buf(),
err,
};
let write = |err| Error::CouldNotWriteFile {
path: path.to_path_buf(),
err,
};
let mut text =
Rope::from_reader(BufReader::new(File::open(path).map_err(open)?)).map_err(open)?;
edit_rope(&mut text, actions).map_err(|err| Error::FileChangedSinceParsed {
path: path.to_path_buf(),
err,
})?;
text.write_to(BufWriter::new(File::create(path).map_err(write)?))
.map_err(write)?;
Ok(())
}
pub fn edit(input: impl Into<Rope>, actions: &[Action]) -> Result<String, ropey::Error> {
let mut input = input.into();
edit_rope(&mut input, actions)?;
Ok(input.into())
}
fn edit_rope(input: &mut Rope, actions: &[Action]) -> Result<(), ropey::Error> {
use itertools::Itertools;
let actions = actions
.iter()
.sorted_by_key(|action| match action {
Action::Delete(start, _) | Action::Replace(start, _, _) => *start,
Action::Insert(idx, _) => Some(*idx),
})
.rev();
for action in actions {
apply_action(input, action)?;
}
Ok(())
}
#[inline]
fn byte_to_char(input: &Rope, idx: Option<&usize>) -> Result<Option<usize>, ropey::Error> {
idx.map(|idx| input.try_byte_to_char(*idx)).transpose()
}
#[inline]
fn apply_action(input: &mut Rope, action: &Action) -> Result<(), ropey::Error> {
match action {
Action::Delete(start, end) => {
match (
byte_to_char(input, start.as_ref())?,
byte_to_char(input, end.as_ref())?,
) {
(Some(start), Some(end)) => input.try_remove(start..end),
(Some(start), None) => input.try_remove(start..),
(None, Some(end)) => input.try_remove(..end),
(None, None) => input.try_remove(..),
}
}
Action::Insert(idx, text) => {
let idx = input.try_byte_to_char(*idx)?;
input.try_insert(idx, text)
}
Action::Replace(start, end, text) => {
apply_action(input, &Action::Delete(*start, *end))?;
let idx = byte_to_char(input, start.as_ref())?.unwrap_or(0);
input.try_insert(idx, text)
}
}
}
#[cfg(test)]
mod tests {
use super::{Action, edit, edit_file};
#[test]
fn edit_handles_non_ascii_before_the_span() {
for input in [
"# Déploiement\nversion: \"1.0.0\"\nname: mon-chart\n",
"# Déploiement à faire\nversion: \"1.0.0\"\n",
"# Deploiement\nversion: \"1.0.0\"\nname: mon-chart\n",
] {
let start = input.find('"').expect("the fixture quotes its version");
let end = start + "\"1.0.0\"".len();
assert_eq!(
edit(input, &[Action::replace(start..=end, "\"2.0.0\"")]).unwrap(),
input.replace("\"1.0.0\"", "\"2.0.0\""),
"input: {input:?}"
);
}
}
#[test]
fn replace_drops_one_character_less_than_the_range_announces() {
assert_eq!(
edit("abcdef", &[Action::replace(1..=3, "X")]).unwrap(),
"aXdef"
);
assert_eq!(
edit("abcdef", &[Action::replace(1..3, "X")]).unwrap(),
"aXcdef"
);
}
#[test]
fn a_span_past_the_end_is_an_error_not_a_panic() {
assert!(edit("short", &[Action::replace(400..=420, "X")]).is_err());
assert!(edit("short", &[Action::insert(400, "X")]).is_err());
assert!(edit("short", &[Action::delete(400..=420)]).is_err());
assert!(edit("short", &[Action::Delete(Some(4), Some(1))]).is_err());
}
#[test]
fn edit_file_reports_the_path_and_leaves_the_file_alone() {
let dir = tempfile::TempDir::with_prefix("adnv-edit").unwrap();
let path = dir.path().join("main.tf");
std::fs::write(&path, "version = \"1.0.0\"\n").unwrap();
let err = edit_file(&path, &[Action::replace(400..=420, "\"2.0.0\"")])
.expect_err("the span is past the end of the file");
assert!(
matches!(&err, crate::Error::FileChangedSinceParsed { path: reported, .. } if reported == &path),
"{err:?}"
);
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"version = \"1.0.0\"\n",
"the file was rewritten even though the edit failed"
);
}
}