1use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
9#[serde(rename_all = "kebab-case")]
10#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
11#[non_exhaustive]
12pub enum FixSafety {
13 Safe,
15 Unsafe,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
21#[serde(rename_all = "kebab-case")]
22#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
23#[non_exhaustive]
24pub enum FixPreference {
25 Highest,
27 Lowest,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "kebab-case")]
34#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
35#[non_exhaustive]
36pub struct Fix {
37 pub title: String,
39 pub safety: FixSafety,
41 pub ops: Vec<FixOp>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub preference: Option<FixPreference>,
47}
48
49impl Fix {
50 #[must_use]
52 pub fn new(title: impl Into<String>, safety: FixSafety, ops: Vec<FixOp>) -> Self {
53 Self {
54 title: title.into(),
55 safety,
56 ops,
57 preference: None,
58 }
59 }
60
61 #[must_use]
63 pub const fn with_preference(mut self, preference: FixPreference) -> Self {
64 self.preference = Some(preference);
65 self
66 }
67}
68
69impl JsonAction {
70 #[must_use]
72 pub const fn insert(key: String, value: serde_json::Value, position: InsertPosition) -> Self {
73 Self::Insert { key, value, position }
74 }
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
79#[serde(rename_all = "kebab-case")]
80#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
81#[non_exhaustive]
82pub enum PathSeg {
83 Key(String),
85 Index(usize),
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
91#[serde(rename_all = "kebab-case")]
92#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
93#[non_exhaustive]
94pub enum InsertPosition {
95 Start,
97 End,
99 Sorted,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(rename_all = "kebab-case")]
106#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
107#[non_exhaustive]
108pub enum JsonAction {
109 Set(serde_json::Value),
111 Remove,
113 #[non_exhaustive]
115 Insert {
116 key: String,
118 value: serde_json::Value,
120 position: InsertPosition,
122 },
123 SortKeys,
125 RenameKey(String),
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(rename_all = "kebab-case")]
132#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
133#[non_exhaustive]
134pub enum YamlAction {
135 Set(serde_json::Value),
137 Remove,
139 Append(serde_json::Value),
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(rename_all = "kebab-case")]
146#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
147#[non_exhaustive]
148pub enum FixOp {
149 #[non_exhaustive]
151 Json {
152 #[serde(serialize_with = "crate::display::serialize_path")]
154 path: PathBuf,
155 pointer: Vec<PathSeg>,
157 action: JsonAction,
159 },
160 #[non_exhaustive]
162 Yaml {
163 #[serde(serialize_with = "crate::display::serialize_path")]
165 path: PathBuf,
166 route: Vec<PathSeg>,
168 action: YamlAction,
170 },
171 #[non_exhaustive]
173 Text {
174 #[serde(serialize_with = "crate::display::serialize_path")]
176 path: PathBuf,
177 edit: TextEdit,
179 },
180 #[non_exhaustive]
182 CreateFile {
183 #[serde(serialize_with = "crate::display::serialize_path")]
185 path: PathBuf,
186 contents: String,
188 },
189 #[non_exhaustive]
191 DeleteFile {
192 #[serde(serialize_with = "crate::display::serialize_path")]
194 path: PathBuf,
195 },
196}
197
198impl FixOp {
199 #[must_use]
201 pub fn json(path: impl Into<PathBuf>, pointer: Vec<PathSeg>, action: JsonAction) -> Self {
202 Self::Json {
203 path: path.into(),
204 pointer,
205 action,
206 }
207 }
208
209 #[must_use]
211 pub fn yaml(path: impl Into<PathBuf>, route: Vec<PathSeg>, action: YamlAction) -> Self {
212 Self::Yaml {
213 path: path.into(),
214 route,
215 action,
216 }
217 }
218
219 #[must_use]
221 pub fn text(path: impl Into<PathBuf>, edit: TextEdit) -> Self {
222 Self::Text {
223 path: path.into(),
224 edit,
225 }
226 }
227
228 #[must_use]
230 pub fn create_file(path: impl Into<PathBuf>, contents: impl Into<String>) -> Self {
231 Self::CreateFile {
232 path: path.into(),
233 contents: contents.into(),
234 }
235 }
236
237 #[must_use]
239 pub fn delete_file(path: impl Into<PathBuf>) -> Self {
240 Self::DeleteFile { path: path.into() }
241 }
242}
243
244#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
246#[serde(rename_all = "kebab-case")]
247#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
248#[non_exhaustive]
249pub struct TextEdit {
250 pub start: u32,
252 pub end: u32,
254 pub replacement: String,
256}
257
258impl TextEdit {
259 #[must_use]
261 pub fn new(start: u32, end: u32, replacement: impl Into<String>) -> Self {
262 Self {
263 start,
264 end,
265 replacement: replacement.into(),
266 }
267 }
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
272#[serde(rename_all = "kebab-case", rename_all_fields = "kebab-case")]
273#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
274#[non_exhaustive]
275pub enum FileChange {
276 Modify {
278 #[serde(serialize_with = "crate::display::serialize_path")]
280 path: PathBuf,
281 edits: Vec<TextEdit>,
283 new_contents: String,
285 },
286 Create {
288 #[serde(serialize_with = "crate::display::serialize_path")]
290 path: PathBuf,
291 contents: String,
293 },
294 Delete {
296 #[serde(serialize_with = "crate::display::serialize_path")]
298 path: PathBuf,
299 },
300}
301
302impl FileChange {
303 #[must_use]
305 pub fn path(&self) -> &std::path::Path {
306 match self {
307 Self::Modify { path, .. } | Self::Create { path, .. } | Self::Delete { path } => path,
308 }
309 }
310}
311
312#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
314#[non_exhaustive]
315pub enum EditError {
316 #[error("edit {start}..{end} is out of bounds for source of {len} bytes")]
318 OutOfBounds {
319 start: u32,
321 end: u32,
323 len: usize,
325 },
326 #[error("offset {offset} is not on a character boundary")]
328 NotCharBoundary {
329 offset: u32,
331 },
332 #[error("edit {}..{} overlaps edit {}..{}", first.0, first.1, second.0, second.1)]
334 Overlap {
335 first: (u32, u32),
337 second: (u32, u32),
339 },
340}
341
342pub fn apply_text_edits(source: &str, edits: &[TextEdit]) -> Result<String, EditError> {
350 let ordered = validated(source, edits)?;
351 let mut out = String::with_capacity(source.len());
352 let cursor = ordered.iter().fold(0, |cursor, edit| {
353 out.push_str(&source[cursor..edit.start as usize]);
354 out.push_str(&edit.replacement);
355 edit.end as usize
356 });
357 out.push_str(&source[cursor..]);
358 Ok(out)
359}
360
361fn validated<'a>(source: &str, edits: &'a [TextEdit]) -> Result<Vec<&'a TextEdit>, EditError> {
362 edits.iter().try_for_each(|edit| check_bounds(source, edit))?;
363 let mut ordered: Vec<&TextEdit> = edits.iter().collect();
364 ordered.sort_by_key(|edit| (edit.start, edit.end));
365 ordered
366 .windows(2)
367 .try_for_each(|pair| check_disjoint(pair[0], pair[1]))?;
368 Ok(ordered)
369}
370
371fn check_bounds(source: &str, edit: &TextEdit) -> Result<(), EditError> {
372 if edit.start > edit.end || edit.end as usize > source.len() {
373 return Err(EditError::OutOfBounds {
374 start: edit.start,
375 end: edit.end,
376 len: source.len(),
377 });
378 }
379 [edit.start, edit.end]
380 .into_iter()
381 .find(|&offset| !source.is_char_boundary(offset as usize))
382 .map_or(Ok(()), |offset| Err(EditError::NotCharBoundary { offset }))
383}
384
385fn check_disjoint(first: &TextEdit, second: &TextEdit) -> Result<(), EditError> {
386 if second.start < first.end {
387 Err(EditError::Overlap {
388 first: (first.start, first.end),
389 second: (second.start, second.end),
390 })
391 } else {
392 Ok(())
393 }
394}
395
396#[cfg(test)]
397#[allow(clippy::unwrap_used, clippy::expect_used)]
398mod tests {
399 use proptest::prelude::*;
400
401 use super::*;
402
403 #[test]
404 fn constructors_build_the_variants() {
405 let pointer = vec![PathSeg::Key("a".into())];
406 assert_eq!(
407 FixOp::json("p.json", pointer.clone(), JsonAction::Remove),
408 FixOp::Json {
409 path: "p.json".into(),
410 pointer: pointer.clone(),
411 action: JsonAction::Remove
412 }
413 );
414 assert_eq!(
415 FixOp::yaml("p.yaml", pointer.clone(), YamlAction::Remove),
416 FixOp::Yaml {
417 path: "p.yaml".into(),
418 route: pointer,
419 action: YamlAction::Remove
420 }
421 );
422 assert_eq!(
423 FixOp::text("t.txt", TextEdit::new(0, 1, "x")),
424 FixOp::Text {
425 path: "t.txt".into(),
426 edit: TextEdit::new(0, 1, "x")
427 }
428 );
429 assert_eq!(
430 FixOp::create_file("n.txt", "body"),
431 FixOp::CreateFile {
432 path: "n.txt".into(),
433 contents: "body".into()
434 }
435 );
436 assert_eq!(FixOp::delete_file("d.txt"), FixOp::DeleteFile { path: "d.txt".into() });
437 assert_eq!(
438 JsonAction::insert("k".into(), serde_json::json!(1), InsertPosition::End),
439 JsonAction::Insert {
440 key: "k".into(),
441 value: serde_json::json!(1),
442 position: InsertPosition::End
443 }
444 );
445 }
446
447 fn edit(start: u32, end: u32, replacement: &str) -> TextEdit {
448 TextEdit {
449 start,
450 end,
451 replacement: replacement.to_owned(),
452 }
453 }
454
455 #[test]
456 fn applies_unordered_edits() {
457 let edits = [edit(6, 11, "there"), edit(0, 5, "howdy")];
458 assert_eq!(apply_text_edits("hello world", &edits).unwrap(), "howdy there");
459 }
460
461 #[test]
462 fn applies_adjacent_edits() {
463 let edits = [edit(2, 4, "CD"), edit(0, 2, "AB"), edit(4, 6, "EF")];
464 assert_eq!(apply_text_edits("abcdef", &edits).unwrap(), "ABCDEF");
465 }
466
467 #[test]
468 fn no_edits_returns_source() {
469 assert_eq!(apply_text_edits("same", &[]).unwrap(), "same");
470 }
471
472 #[test]
473 fn inserts_and_deletes() {
474 let edits = [edit(0, 0, ">"), edit(1, 3, ""), edit(4, 4, "<")];
475 assert_eq!(apply_text_edits("abcd", &edits).unwrap(), ">ad<");
476 }
477
478 #[test]
479 fn same_offset_inserts_keep_input_order() {
480 let edits = [edit(1, 1, "X"), edit(1, 1, "Y"), edit(1, 1, "Z")];
481 assert_eq!(apply_text_edits("ab", &edits).unwrap(), "aXYZb");
482 }
483
484 #[test]
485 fn insert_at_start_of_replacement_comes_first() {
486 let edits = [edit(1, 2, "R"), edit(1, 1, "I")];
487 assert_eq!(apply_text_edits("abc", &edits).unwrap(), "aIRc");
488 }
489
490 #[test]
491 fn rejects_invalid_edits() {
492 let cases = [
493 (
494 "abcdef",
495 vec![edit(0, 4, "x"), edit(2, 5, "y")],
496 EditError::Overlap {
497 first: (0, 4),
498 second: (2, 5),
499 },
500 ),
501 (
502 "abcdef",
503 vec![edit(1, 3, "x"), edit(2, 2, "y")],
504 EditError::Overlap {
505 first: (1, 3),
506 second: (2, 2),
507 },
508 ),
509 (
510 "abcdef",
511 vec![edit(2, 4, "x"), edit(2, 4, "y")],
512 EditError::Overlap {
513 first: (2, 4),
514 second: (2, 4),
515 },
516 ),
517 (
518 "abc",
519 vec![edit(2, 9, "x")],
520 EditError::OutOfBounds {
521 start: 2,
522 end: 9,
523 len: 3,
524 },
525 ),
526 (
527 "abc",
528 vec![edit(3, 2, "x")],
529 EditError::OutOfBounds {
530 start: 3,
531 end: 2,
532 len: 3,
533 },
534 ),
535 (
536 "a\u{e9}",
537 vec![edit(2, 3, "x")],
538 EditError::NotCharBoundary { offset: 2 },
539 ),
540 (
541 "\u{1F600}",
542 vec![edit(1, 4, "x")],
543 EditError::NotCharBoundary { offset: 1 },
544 ),
545 ];
546 for (source, edits, expected) in cases {
547 assert_eq!(apply_text_edits(source, &edits), Err(expected), "source {source:?}");
548 }
549 }
550
551 #[test]
552 fn handles_multibyte_boundaries() {
553 let edits = [edit(1, 3, "e")];
554 assert_eq!(apply_text_edits("a\u{e9}b", &edits).unwrap(), "aeb");
555 }
556
557 fn reference(source: &str, edits: &[TextEdit]) -> String {
558 let mut sorted = edits.to_vec();
559 sorted.sort_by_key(|e| (e.start, e.end));
560 let mut out = source.to_owned();
561 for e in sorted.iter().rev() {
562 out.replace_range(e.start as usize..e.end as usize, &e.replacement);
563 }
564 out
565 }
566
567 fn build_edits(source: &str, cuts: &[usize], replacements: &[String], rotation: usize) -> Vec<TextEdit> {
568 let boundaries: Vec<usize> = source.char_indices().map(|(i, _)| i).chain([source.len()]).collect();
569 let mut offsets: Vec<u32> = cuts
570 .iter()
571 .map(|c| u32::try_from(boundaries[c % boundaries.len()]).unwrap())
572 .collect();
573 offsets.sort_unstable();
574 let mut edits: Vec<TextEdit> = offsets
575 .as_chunks::<2>()
576 .0
577 .iter()
578 .zip(replacements)
579 .map(|(&[start, end], rep)| edit(start, end, rep))
580 .collect();
581 if !edits.is_empty() {
582 let len = edits.len();
583 edits.rotate_left(rotation % len);
584 }
585 edits
586 }
587
588 proptest! {
589 #[test]
590 fn matches_naive_reference(
591 source in "[a-z\u{e9}\u{1F600}\n]{0,24}",
592 cuts in proptest::collection::vec(0usize..1000, 0..10),
593 replacements in proptest::collection::vec("[xyz\u{20ac}]{0,3}", 5),
594 rotation in 0usize..8,
595 ) {
596 let edits = build_edits(&source, &cuts, &replacements, rotation);
597 prop_assert_eq!(apply_text_edits(&source, &edits).unwrap(), reference(&source, &edits));
598 }
599 }
600
601 #[test]
602 fn constructors_fill_every_field() {
603 let edit = TextEdit::new(1, 3, "x");
604 assert_eq!((edit.start, edit.end, edit.replacement.as_str()), (1, 3, "x"));
605 let fix = Fix::new("Title", FixSafety::Unsafe, vec![]);
606 assert_eq!(
607 fix,
608 Fix {
609 title: "Title".to_owned(),
610 safety: FixSafety::Unsafe,
611 ops: Vec::new(),
612 preference: None,
613 }
614 );
615 }
616}