Skip to main content

skif_core/
fix.rs

1//! Fixes: structured operations, text edits and their in-memory application.
2
3use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6
7/// How risky applying a fix is.
8#[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    /// Never changes behavior.
14    Safe,
15    /// May change behavior; requires opt-in.
16    Unsafe,
17}
18
19/// The version a fix that is one of several alternatives aligns on, which `[policy] prefer` selects among them.
20#[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    /// Aligns on the highest version in use.
26    Highest,
27    /// Aligns on the lowest version in use.
28    Lowest,
29}
30
31/// A titled, ordered group of operations that repairs one diagnostic.
32#[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    /// Short description shown to the user.
38    pub title: String,
39    /// Risk level.
40    pub safety: FixSafety,
41    /// Operations, applied in order.
42    pub ops: Vec<FixOp>,
43    /// Which preference selects this fix among alternatives of one diagnostic; `None` for a fix that is no
44    /// version-aligning alternative.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub preference: Option<FixPreference>,
47}
48
49impl Fix {
50    /// A fix titled `title` that performs `ops` in order.
51    #[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    /// Marks the fix as the alternative that `preference` selects.
62    #[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    /// Inserts `key` with `value` into the object node at `position`.
71    #[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/// One step of a structured path into a JSON or YAML document.
78#[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    /// Object or mapping key.
84    Key(String),
85    /// Array or sequence index.
86    Index(usize),
87}
88
89/// Where a new key is placed inside an object.
90#[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    /// Before all existing keys.
96    Start,
97    /// After all existing keys.
98    End,
99    /// At its alphabetical position.
100    Sorted,
101}
102
103/// Edit applied to a JSON node.
104#[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    /// Replace the node with a value.
110    Set(serde_json::Value),
111    /// Remove the node.
112    Remove,
113    /// Insert a key into the object node.
114    #[non_exhaustive]
115    Insert {
116        /// New key.
117        key: String,
118        /// New value.
119        value: serde_json::Value,
120        /// Placement among existing keys.
121        position: InsertPosition,
122    },
123    /// Sort the keys of the object node.
124    SortKeys,
125    /// Rename the node's key in its parent object.
126    RenameKey(String),
127}
128
129/// Edit applied to a YAML node.
130#[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    /// Replace the node with a value.
136    Set(serde_json::Value),
137    /// Remove the node.
138    Remove,
139    /// Append a value to the sequence node.
140    Append(serde_json::Value),
141}
142
143/// A single mutation of the workspace.
144#[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    /// Structured edit of a JSON file.
150    #[non_exhaustive]
151    Json {
152        /// Workspace-relative file.
153        #[serde(serialize_with = "crate::display::serialize_path")]
154        path: PathBuf,
155        /// Path to the target node; empty addresses the root.
156        pointer: Vec<PathSeg>,
157        /// What to do with the node.
158        action: JsonAction,
159    },
160    /// Structured edit of a YAML file.
161    #[non_exhaustive]
162    Yaml {
163        /// Workspace-relative file.
164        #[serde(serialize_with = "crate::display::serialize_path")]
165        path: PathBuf,
166        /// Path to the target node; empty addresses the root.
167        route: Vec<PathSeg>,
168        /// What to do with the node.
169        action: YamlAction,
170    },
171    /// Byte-range edit of a text file.
172    #[non_exhaustive]
173    Text {
174        /// Workspace-relative file.
175        #[serde(serialize_with = "crate::display::serialize_path")]
176        path: PathBuf,
177        /// The edit.
178        edit: TextEdit,
179    },
180    /// Create a file.
181    #[non_exhaustive]
182    CreateFile {
183        /// Workspace-relative file.
184        #[serde(serialize_with = "crate::display::serialize_path")]
185        path: PathBuf,
186        /// Full contents.
187        contents: String,
188    },
189    /// Delete a file.
190    #[non_exhaustive]
191    DeleteFile {
192        /// Workspace-relative file.
193        #[serde(serialize_with = "crate::display::serialize_path")]
194        path: PathBuf,
195    },
196}
197
198impl FixOp {
199    /// A structured edit of the JSON file `path`; `pointer` addresses the node, empty for the root.
200    #[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    /// A structured edit of the YAML file `path`; `route` addresses the node, empty for the root.
210    #[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    /// A byte-range edit of the text file `path`.
220    #[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    /// Creates the file `path` with `contents`.
229    #[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    /// Deletes the file `path`.
238    #[must_use]
239    pub fn delete_file(path: impl Into<PathBuf>) -> Self {
240        Self::DeleteFile { path: path.into() }
241    }
242}
243
244/// Replacement of the half-open byte range `[start, end)`; `start == end` inserts.
245#[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    /// First byte replaced.
251    pub start: u32,
252    /// One past the last byte replaced.
253    pub end: u32,
254    /// Text put in place of the range.
255    pub replacement: String,
256}
257
258impl TextEdit {
259    /// Replaces the bytes `start..end` with `replacement`.
260    #[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/// The net effect of fixes on one file, ready to be written.
271#[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    /// An existing file is modified.
277    Modify {
278        /// Workspace-relative file.
279        #[serde(serialize_with = "crate::display::serialize_path")]
280        path: PathBuf,
281        /// Text edits that produced `new_contents`, when the change is textual.
282        edits: Vec<TextEdit>,
283        /// Full resulting contents.
284        new_contents: String,
285    },
286    /// A file is created.
287    Create {
288        /// Workspace-relative file.
289        #[serde(serialize_with = "crate::display::serialize_path")]
290        path: PathBuf,
291        /// Full contents.
292        contents: String,
293    },
294    /// A file is deleted.
295    Delete {
296        /// Workspace-relative file.
297        #[serde(serialize_with = "crate::display::serialize_path")]
298        path: PathBuf,
299    },
300}
301
302impl FileChange {
303    /// The workspace-relative file the change touches.
304    #[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/// Reason a set of [`TextEdit`]s cannot be applied.
313#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
314#[non_exhaustive]
315pub enum EditError {
316    /// An edit reaches past the end of the source, or ends before it starts.
317    #[error("edit {start}..{end} is out of bounds for source of {len} bytes")]
318    OutOfBounds {
319        /// Edit start.
320        start: u32,
321        /// Edit end.
322        end: u32,
323        /// Source length in bytes.
324        len: usize,
325    },
326    /// An edit boundary falls inside a multibyte character.
327    #[error("offset {offset} is not on a character boundary")]
328    NotCharBoundary {
329        /// The offending offset.
330        offset: u32,
331    },
332    /// Two edits touch overlapping ranges.
333    #[error("edit {}..{} overlaps edit {}..{}", first.0, first.1, second.0, second.1)]
334    Overlap {
335        /// Earlier edit as `(start, end)`.
336        first: (u32, u32),
337        /// Later edit as `(start, end)`.
338        second: (u32, u32),
339    },
340}
341
342/// Applies `edits` to `source`, independent of the order they are given in.
343///
344/// Edits must not overlap; adjacent edits are fine. Zero-width inserts at the same offset keep their input order.
345///
346/// # Errors
347///
348/// Returns [`EditError`] when an edit is out of bounds, splits a character, or overlaps another edit.
349pub 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}