textum 0.4.0

A syntactic patching library with char-level granularity
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
//! Core patch types and application logic.
//!
//! A patch represents a single atomic edit operation on a file, defined by a character range
//! and optional replacement text. Patches can be created from line-based positions (for
//! compatibility with tools like cargo diagnostics) or directly from character indices.

use crate::Rope;
#[cfg(feature = "facet")]
use facet::Facet;

pub mod error;
pub use error::PatchError;

use crate::snip::snippet::{Boundary, BoundaryMode, Snippet};
use crate::snip::target::Target;

/// A single atomic patch operation on a file.
///
/// Patches operate through the Snippet system, providing flexible target matching
/// and boundary semantics. Character positions are 0-indexed and represent positions
/// in the file as a sequence of Unicode scalar values.
///
/// # Examples
///
/// ```
/// use textum::{Patch, Target, Boundary, BoundaryMode, Rope, Snippet};
///
/// // Replace using literal target
/// let mut rope = Rope::from_str("hello world");
/// let patch = Patch::from_literal_target(
///     "main.rs".to_string(),
///     "world",
///     BoundaryMode::Include,
///     "rust",
/// );
/// patch.apply(&mut rope).unwrap();
/// assert_eq!(rope.to_string(), "hello rust");
///
/// // Delete using line range
/// let mut rope = Rope::from_str("line1\nline2\nline3\n");
/// let patch = Patch::from_line_range(
///     "main.rs".to_string(),
///     1,
///     2,
///     "",
/// );
/// patch.apply(&mut rope).unwrap();
/// assert_eq!(rope.to_string(), "line1\nline3\n");
/// ```
#[derive(Debug, Clone)]
#[cfg_attr(feature = "facet", derive(Facet))]
pub struct Patch {
    /// File path this patch applies to.
    ///
    /// Required when using `PatchSet::apply_to_files()`. Can be `None` for
    /// in-memory operations using `apply()` or `apply_to_string()`.
    pub file: Option<String>,

    /// Snippet defining the target range for this patch.
    pub snippet: Snippet,

    /// Replacement text to insert at the resolved range.
    ///
    /// Empty string performs deletion of the resolved range.
    pub replacement: String,

    /// Optional symbol path for robust positioning (non-functional, reserved for future use).
    #[cfg_attr(feature = "facet", facet(default))]
    #[cfg(feature = "symbol_path")]
    pub symbol_path: Option<Vec<String>>,
}

impl Patch {
    /// Apply this patch to a rope in-place.
    ///
    /// The rope is modified by resolving the snippet to a character range, then
    /// removing that range and inserting the replacement text. Changes are applied
    /// atomically - if the patch cannot be applied, the rope is left unchanged.
    ///
    /// # Errors
    ///
    /// Returns `PatchError` if the snippet cannot be resolved or if the resolved
    /// range extends beyond the rope's character count.
    ///
    /// # Examples
    ///
    /// ```
    /// use textum::{Patch, Rope};
    ///
    /// let mut rope = Rope::from_str("hello world");
    /// let patch = Patch::from_literal_target(
    ///     "test.txt".to_string(),
    ///     "world",
    ///     textum::BoundaryMode::Include,
    ///     "rust",
    /// );
    ///
    /// patch.apply(&mut rope).unwrap();
    /// assert_eq!(rope.to_string(), "hello rust");
    /// ```
    pub fn apply(&self, rope: &mut Rope) -> Result<(), PatchError> {
        let resolution = self.snippet.resolve(rope)?;

        if resolution.end > rope.len_chars() {
            return Err(PatchError::RangeOutOfBounds);
        }

        // Remove the range
        if resolution.start < resolution.end {
            rope.remove(resolution.start..resolution.end);
        }

        // Insert replacement
        rope.insert(resolution.start, &self.replacement);

        Ok(())
    }

    /// Apply this patch to a file, returning the modified content.
    ///
    /// This reads a file from disk, applies the patch, and returns the result
    /// without writing back to disk. Use `write_to_file()` to write the result
    /// back to disk.
    ///
    /// # Errors
    ///
    /// Returns `PatchError` if:
    /// - the file path is not set (`file` is `None`)
    /// - the file cannot be read
    /// - the snippet cannot be resolved
    /// - the resolved range is invalid
    ///
    /// # Examples
    ///
    /// ```
    /// use textum::{Patch, BoundaryMode};
    ///
    /// let patch = Patch::from_literal_target(
    ///     "tests/fixtures/sample.txt".to_string(),
    ///     "world",
    ///     BoundaryMode::Include,
    ///     "rust",
    /// );
    ///
    /// let result = patch.apply_to_file()?;
    /// println!("Modified content: {}", result);
    /// # Ok::<(), textum::PatchError>(())
    /// ```
    pub fn apply_to_file(&self) -> Result<String, PatchError> {
        let file_path = self.file.as_ref().ok_or(PatchError::MissingFilePath)?;

        let content = std::fs::read_to_string(file_path).map_err(PatchError::IoError)?;
        let mut rope = Rope::from_str(&content);

        self.apply(&mut rope)?;

        Ok(rope.to_string())
    }

    /// Apply this patch to a file and write the result back to disk.
    ///
    /// This is a convenience method that reads a file from disk, applies the patch,
    /// and writes the result back to the same file. The file path must be set in the
    /// `Patch` struct.
    ///
    /// # Errors
    ///
    /// Returns `PatchError` if:
    /// - the file path is not set (`file` is `None`)
    /// - the file cannot be read
    /// - the snippet cannot be resolved
    /// - the resolved range is invalid
    /// - the file cannot be written
    ///
    /// # Examples
    ///
    /// ```no_run
    /// use textum::{Patch, BoundaryMode};
    ///
    /// let patch = Patch::from_literal_target(
    ///     "tests/fixtures/sample.txt".to_string(),
    ///     "world",
    ///     BoundaryMode::Include,
    ///     "rust",
    /// );
    ///
    /// patch.write_to_file()?;
    /// # Ok::<(), textum::PatchError>(())
    /// ```
    pub fn write_to_file(&self) -> Result<(), PatchError> {
        let file_path = self.file.as_ref().ok_or(PatchError::MissingFilePath)?;
        let content = self.apply_to_file()?;
        std::fs::write(file_path, content).map_err(PatchError::IoError)?;
        Ok(())
    }

    /// Create a patch for in-memory string operations without a file path.
    ///
    /// Use this constructor when you plan to apply patches via `apply()` or
    /// `apply_to_string()` on in-memory content. For file-based operations,
    /// use constructors like `from_literal_target()` which require a file path.
    ///
    /// # Examples
    ///
    /// ```
    /// use textum::{Patch, Snippet, Boundary, BoundaryMode, Target};
    ///
    /// let snippet = Snippet::At(
    ///     Boundary::new(
    ///         Target::Literal("world".to_string()),
    ///         BoundaryMode::Include,
    ///     )
    /// );
    ///
    /// let patch = Patch::in_memory(snippet, "rust");
    /// let result = patch.apply_to_string("hello world").unwrap();
    /// assert_eq!(result, "hello rust");
    /// ```
    #[must_use]
    pub fn in_memory(snippet: Snippet, replacement: impl Into<String>) -> Self {
        Self {
            file: None,
            snippet,
            replacement: replacement.into(),
            #[cfg(feature = "symbol_path")]
            symbol_path: None,
        }
    }

    /// Apply this patch to a string, returning the modified string.
    ///
    /// This is a convenience method for working with strings directly without needing
    /// to construct a `Rope`. The string is converted to a rope internally, the patch
    /// is applied, and the result is converted back to a string.
    ///
    /// # Arguments
    ///
    /// * `content` - The string content to apply the patch to
    ///
    /// # Returns
    ///
    /// Returns the modified string with the patch applied.
    ///
    /// # Errors
    ///
    /// Returns `PatchError` if the snippet cannot be resolved or if the resolved
    /// range extends beyond the content's character count.
    ///
    /// # Examples
    ///
    /// ```
    /// use textum::{Patch, Snippet, Boundary, BoundaryMode, Target};
    ///
    /// let content = "hello world";
    /// let snippet = Snippet::At(
    ///     Boundary::new(
    ///         Target::Literal("world".to_string()),
    ///         BoundaryMode::Include,
    ///     )
    /// );
    ///
    /// let patch = Patch::in_memory(snippet, "rust");
    /// let result = patch.apply_to_string(content).unwrap();
    /// assert_eq!(result, "hello rust");
    /// ```
    pub fn apply_to_string(&self, content: &str) -> Result<String, PatchError> {
        let mut rope = Rope::from_str(content);
        self.apply(&mut rope)?;
        Ok(rope.to_string())
    }

    /// Create a patch from a literal string target.
    ///
    /// Constructs a patch that matches the first occurrence of `needle` in the file
    /// and applies the boundary mode to determine inclusion/exclusion.
    ///
    /// # Arguments
    ///
    /// * `file` - Path to the file this patch targets
    /// * `needle` - Exact string to match
    /// * `mode` - Whether to include, exclude, or extend the boundary
    /// * `replacement` - Text to insert (empty string for deletion)
    ///
    /// # Examples
    ///
    /// ```
    /// use textum::{Patch, BoundaryMode};
    ///
    /// let patch = Patch::from_literal_target(
    ///     "src/main.rs".to_string(),
    ///     "old_name",
    ///     BoundaryMode::Include,
    ///     "new_name",
    /// );
    /// ```
    #[must_use]
    pub fn from_literal_target(
        file: String,
        needle: &str,
        mode: BoundaryMode,
        replacement: impl Into<String>,
    ) -> Self {
        let target = Target::Literal(needle.to_string());
        let boundary = Boundary::new(target, mode);
        let snippet = Snippet::At(boundary);
        Self {
            file: Some(file),
            snippet,
            replacement: replacement.into(),
            #[cfg(feature = "symbol_path")]
            symbol_path: None,
        }
    }

    /// Create a patch from a line range.
    ///
    /// Constructs a patch that targets a range of lines, with the start line included
    /// and the end line excluded (half-open range semantics).
    ///
    /// # Arguments
    ///
    /// * `file` - Path to the file this patch targets
    /// * `start_line` - Starting line number (0-indexed, inclusive)
    /// * `end_line` - Ending line number (0-indexed, exclusive)
    /// * `replacement` - Text to insert (empty string for deletion)
    ///
    /// # Examples
    ///
    /// ```
    /// use textum::Patch;
    ///
    /// // Delete lines 5-10
    /// let patch = Patch::from_line_range(
    ///     "src/main.rs".to_string(),
    ///     5,
    ///     10,
    ///     "",
    /// );
    /// ```
    #[must_use]
    pub fn from_line_range(
        file: String,
        start_line: usize,
        end_line: usize,
        replacement: impl Into<String>,
    ) -> Self {
        let start = Boundary::new(Target::Line(start_line), BoundaryMode::Include);
        let end = Boundary::new(Target::Line(end_line), BoundaryMode::Exclude);
        let snippet = Snippet::Between { start, end };
        Self {
            file: Some(file),
            snippet,
            replacement: replacement.into(),
            #[cfg(feature = "symbol_path")]
            symbol_path: None,
        }
    }

    /// Create a patch from line-based positions.
    ///
    /// This is useful for interoperating with tools that report positions in terms of
    /// lines and columns (like cargo diagnostics). Line and column indices are 0-based.
    ///
    /// # Arguments
    ///
    /// * `file` - Path to the file this patch targets
    /// * `line_start` - Starting line number (0-indexed)
    /// * `col_start` - Starting column within the line (0-indexed)
    /// * `line_end` - Ending line number (0-indexed)
    /// * `col_end` - Ending column within the line (0-indexed)
    /// * `rope` - A rope containing the file content, used to validate positions
    /// * `replacement` - Replacement text
    ///
    /// # Examples
    ///
    /// ```
    /// use textum::{Patch, Rope};
    ///
    /// let rope = Rope::from_str("line 1\nline 2\nline 3");
    /// let patch = Patch::from_line_positions(
    ///     "test.txt".to_string(),
    ///     1,
    ///     0,
    ///     1,
    ///     6,
    ///     &rope,
    ///     "EDITED",
    /// );
    /// ```
    #[must_use]
    pub fn from_line_positions(
        file: String,
        line_start: usize,
        col_start: usize,
        line_end: usize,
        col_end: usize,
        _rope: &Rope,
        replacement: impl Into<String>,
    ) -> Self {
        // For a range spanning multiple positions, use Between with two Position targets
        let start_target = Target::Position {
            line: line_start + 1, // Convert to 1-indexed
            col: col_start + 1,
        };
        let end_target = Target::Position {
            line: line_end + 1,
            col: col_end + 1,
        };

        let start = Boundary::new(start_target, BoundaryMode::Include);
        let end = Boundary::new(end_target, BoundaryMode::Exclude);
        let snippet = Snippet::Between { start, end };

        Self {
            file: Some(file),
            snippet,
            replacement: replacement.into(),
            #[cfg(feature = "symbol_path")]
            symbol_path: None,
        }
    }
}