Skip to main content

Maintain/Eliminate/Transform/
Patch.rs

1//=============================================================================//
2// File Path: Element/Maintain/Source/Eliminate/Transform/Patch.rs
3//=============================================================================//
4// Module: Patch - Span-based source text patching
5//
6// After the AST eliminator runs, Patch locates the byte ranges of the
7// removed let-statements and their substitution sites in the ORIGINAL source
8// text (using proc_macro2 Span offsets) and splices only those ranges,
9// preserving every other byte including inline comments and blank lines.
10//
11// Design constraints:
12//   - proc_macro2 span byte offsets are only reliable when the crate is built
13//     with the "span-locations" feature (which is the default for proc-macro
14//     contexts).  When offsets are unavailable (start == end == 0 for a
15//     non-empty token), ApplyPatches returns None and the caller falls back to
16//     prettyplease.
17//   - Patches must be non-overlapping and sorted by start offset.  If two edits
18//     would overlap (should never happen given the eliminator logic but
19//     defended against) the function returns None.
20//   - A removed let-statement line is deleted including its trailing newline so
21//     blank lines are not left behind.
22//=============================================================================//
23
24use proc_macro2::Span;
25use quote::ToTokens;
26use syn::{Expr, Stmt, visit::Visit};
27
28// ---------------------------------------------------------------------------
29// Public types
30// ---------------------------------------------------------------------------
31
32/// A single byte-range replacement in the original source text.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Patch {
35	/// Start byte offset in the original source (inclusive).
36	pub Start:usize,
37
38	/// End byte offset in the original source (exclusive).
39	pub End:usize,
40
41	/// Replacement text. Empty string deletes the range.
42	pub Replacement:String,
43}
44
45// ---------------------------------------------------------------------------
46// Public API
47// ---------------------------------------------------------------------------
48
49/// Apply a sorted list of non-overlapping patches to Source.
50///
51/// Returns None when:
52///   - Any patch has Start > End.
53///   - Any two patches overlap.
54///   - Any offset is out of bounds for Source.
55pub fn ApplyPatches(Source:&str, Patches:&[Patch]) -> Option<String> {
56	if Patches.is_empty() {
57		return Some(Source.to_string());
58	}
59
60	let Bytes = Source.as_bytes();
61
62	let mut Result = String::with_capacity(Source.len());
63
64	let mut Cursor = 0usize;
65
66	for P in Patches {
67		if P.Start > P.End || P.End > Bytes.len() {
68			return None;
69		}
70
71		if P.Start < Cursor {
72			// Overlap with previous patch.
73			return None;
74		}
75
76		// Copy unchanged bytes up to this patch.
77		Result.push_str(&Source[Cursor..P.Start]);
78
79		// Apply replacement (may be empty = delete).
80		Result.push_str(&P.Replacement);
81
82		Cursor = P.End;
83	}
84
85	// Copy tail after last patch.
86	Result.push_str(&Source[Cursor..]);
87
88	Some(Result)
89}
90
91/// Extract a byte range from a proc_macro2 Span relative to Source.
92///
93/// Returns None when span information is unavailable (both offsets are 0
94/// for a token that is clearly not at the very start of the file, which
95/// indicates that span-locations are not compiled in).
96pub fn SpanBytes(Span:Span, Source:&str) -> Option<(usize, usize)> {
97	let Start = Span.start();
98
99	let End = Span.end();
100
101	// proc_macro2 LineColumn is 1-based. Convert to byte offsets.
102	let StartByte = LineColToByte(Source, Start.line, Start.column)?;
103
104	let EndByte = LineColToByte(Source, End.line, End.column)?;
105
106	if StartByte == 0 && EndByte == 0 {
107		// Likely a call_site span with no location info.
108		return None;
109	}
110
111	Some((StartByte, EndByte))
112}
113
114/// Convert a 1-based (line, col) pair to a byte offset in Source.
115/// col is 0-based character offset within the line (proc_macro2 convention).
116pub fn LineColToByte(Source:&str, Line:usize, Col:usize) -> Option<usize> {
117	if Line == 0 {
118		return None;
119	}
120
121	let mut CurrentLine = 1usize;
122
123	let mut LineStart = 0usize;
124
125	for (ByteIdx, Ch) in Source.char_indices() {
126		if CurrentLine == Line {
127			LineStart = ByteIdx;
128
129			break;
130		}
131
132		if Ch == '\n' {
133			CurrentLine += 1;
134		}
135	}
136
137	if CurrentLine != Line {
138		return None;
139	}
140
141	// Walk Col characters forward from LineStart.
142	let mut ByteOffset = LineStart;
143
144	for _ in 0..Col {
145		if ByteOffset >= Source.len() {
146			return None;
147		}
148
149		let Ch = Source[ByteOffset..].chars().next()?;
150
151		ByteOffset += Ch.len_utf8();
152	}
153
154	Some(ByteOffset)
155}
156
157/// Given a Stmt::Local, return the byte range of the entire statement line
158/// in Source, including the trailing newline if present.
159pub fn StmtLineRange(Stmt:&Stmt, Source:&str) -> Option<(usize, usize)> {
160	let LocalSpan = StmtSpan(Stmt)?;
161
162	let (Start, End) = SpanBytes(LocalSpan, Source)?;
163
164	// Extend End forward to consume the trailing newline (and any\r).
165	let Bytes = Source.as_bytes();
166
167	let mut LineEnd = End;
168
169	while LineEnd < Bytes.len() && Bytes[LineEnd] != b'\n' {
170		LineEnd += 1;
171	}
172
173	if LineEnd < Bytes.len() && Bytes[LineEnd] == b'\n' {
174		LineEnd += 1;
175	}
176
177	// Also consume leading whitespace before Start on the same line
178	// so the blank indentation is not left behind.
179	let mut LineStart = Start;
180
181	while LineStart > 0 && Bytes[LineStart - 1] != b'\n' {
182		LineStart -= 1;
183	}
184
185	Some((LineStart, LineEnd))
186}
187
188// ---------------------------------------------------------------------------
189// Internal helpers
190// ---------------------------------------------------------------------------
191
192fn StmtSpan(Stmt:&Stmt) -> Option<Span> {
193	let mut Tokens = proc_macro2::TokenStream::new();
194
195	Stmt.to_tokens(&mut Tokens);
196
197	let Trees:Vec<_> = Tokens.into_iter().collect();
198
199	if Trees.is_empty() {
200		return None;
201	}
202
203	let First = Trees.first()?.span();
204
205	let Last = Trees.last()?.span();
206
207	First.join(Last)
208}
209
210/// Collect all Spans of a plain-identifier Expr::Path matching Target
211/// within an expression tree.
212pub struct SpanCollector<'a> {
213	pub Target:&'a str,
214
215	pub Spans:Vec<Span>,
216}
217
218impl<'a, 'ast> Visit<'ast> for SpanCollector<'a> {
219	fn visit_expr_path(&mut self, Node:&'ast syn::ExprPath) {
220		if Node.qself.is_none() {
221			if let Some(I) = Node.path.get_ident() {
222				if I == self.Target {
223					self.Spans.push(I.span());
224				}
225			}
226		}
227	}
228}
229
230// ---------------------------------------------------------------------------
231// Unit tests
232// ---------------------------------------------------------------------------
233
234#[cfg(test)]
235mod Tests {
236
237	use super::*;
238
239	#[test]
240	fn ApplyNoPatches() {
241		let Src = "hello world";
242
243		assert_eq!(ApplyPatches(Src, &[]).unwrap(), Src);
244	}
245
246	#[test]
247	fn ApplySingleDelete() {
248		let Src = "abc def ghi";
249
250		let P = Patch { Start:4, End:8, Replacement:String::new() };
251
252		assert_eq!(ApplyPatches(Src, &[P]).unwrap(), "abc ghi");
253	}
254
255	#[test]
256	fn ApplySingleReplace() {
257		let Src = "let X = 5;\nuse_x(X);";
258
259		// Replace the second `X` (offset 16) with `5`.
260		let P = Patch { Start:16, End:17, Replacement:"5".to_string() };
261
262		assert_eq!(ApplyPatches(Src, &[P]).unwrap(), "let X = 5;\nuse_x(5);");
263	}
264
265	#[test]
266	fn ApplyTwoPatches() {
267		let Src = "AABBCC";
268
269		let Patches = vec![
270			Patch { Start:0, End:2, Replacement:"XX".to_string() },
271			Patch { Start:4, End:6, Replacement:"YY".to_string() },
272		];
273
274		assert_eq!(ApplyPatches(Src, &Patches).unwrap(), "XXBBYY");
275	}
276
277	#[test]
278	fn OverlappingPatchesReturnNone() {
279		let Src = "ABCDE";
280
281		let Patches = vec![
282			Patch { Start:1, End:4, Replacement:"X".to_string() },
283			Patch { Start:3, End:5, Replacement:"Y".to_string() },
284		];
285
286		assert!(ApplyPatches(Src, &Patches).is_none());
287	}
288
289	#[test]
290	fn OutOfBoundsPatchReturnsNone() {
291		let Src = "ABC";
292
293		let P = Patch { Start:2, End:10, Replacement:String::new() };
294
295		assert!(ApplyPatches(Src, &[P]).is_none());
296	}
297
298	#[test]
299	fn LineColToByteFirstLine() {
300		let Src = "hello\nworld";
301
302		assert_eq!(LineColToByte(Src, 1, 0), Some(0));
303
304		assert_eq!(LineColToByte(Src, 1, 5), Some(5));
305	}
306
307	#[test]
308	fn LineColToByteSecondLine() {
309		let Src = "hello\nworld";
310
311		assert_eq!(LineColToByte(Src, 2, 0), Some(6));
312
313		assert_eq!(LineColToByte(Src, 2, 5), Some(11));
314	}
315
316	#[test]
317	fn LineColToByteOutOfRange() {
318		let Src = "hi";
319
320		assert_eq!(LineColToByte(Src, 5, 0), None);
321	}
322}