Skip to main content

Maintain/Eliminate/Transform/
mod.rs

1//=============================================================================//
2// File Path: Element/Maintain/Source/Eliminate/Transform/mod.rs
3//=============================================================================//
4// Module: Transform - AST transformation pipeline
5//
6// Two entry points:
7//
8//   Run(source, options)
9//     Original behaviour: parse with syn, run the VisitMut eliminator,
10//     then attempt span-based text patching to preserve comments and
11//     whitespace. Falls back to prettyplease::unparse when span data is
12//     unavailable. Used by the Reformat path and by all existing unit tests
13//     in Inline.rs (which compare output against prettyplease-normalised
14//     expected values).
15//
16//   RunPreserve(source, options)
17//     Preserve-layout behaviour (default when Options.Reformat == false):
18//     identifies inlinable bindings via the same Collect/Safe/Count pipeline,
19//     then applies targeted text substitutions to the original source string
20//     without touching anything outside the affected lines.  Comments, blank
21//     lines, section banners, and the original indentation style survive
22//     unchanged.  Uses NO proc_macro2 span APIs so no extra Cargo features
23//     are required.
24//
25// Returns `Ok(None)` when no bindings were eliminated.
26//=============================================================================//
27
28pub mod Collect;
29
30pub mod Count;
31
32pub mod Inline;
33
34pub mod Patch;
35
36pub mod Safe;
37
38use super::{Definition, Error};
39
40// ---------------------------------------------------------------------------
41// Original entry point - span-based patch path with prettyplease fallback
42// (signature identical to Current; all Inline.rs tests call this function)
43// ---------------------------------------------------------------------------
44
45/// Parse `Source`, run up to [`super::Constant::MaxIterations`] elimination
46/// passes, then return the patched source text.
47///
48/// Preferred path: span-based text patching via `TryPatchSource` preserves
49/// inline comments and blank lines. Falls back to `prettyplease::unparse`
50/// when span-location data is unavailable.
51///
52/// Returns `Ok(None)` when no bindings were eliminated (caller can skip the
53/// write-back).
54pub fn Run(Source:&str, Options:&Definition::Options) -> Error::Result<Option<String>> {
55	let mut Ast:syn::File = syn::parse_str(Source).map_err(|E| Error::Error::Parse { Path:String::new(), Source:E })?;
56
57	let mut AnyChanged = false;
58
59	for _ in 0..super::Constant::MaxIterations {
60		let mut Eliminator = Inline::Eliminator::new(Options);
61
62		syn::visit_mut::visit_file_mut(&mut Eliminator, &mut Ast);
63
64		if Eliminator.Changed {
65			AnyChanged = true;
66		} else {
67			break;
68		}
69	}
70
71	if !AnyChanged {
72		return Ok(None);
73	}
74
75	// Attempt span-based text patching to preserve comments and whitespace.
76	// Build the patched output by comparing the original AST (re-parsed from
77	// Source so spans are relative to Source) against the mutated Ast.
78	//
79	// Strategy: re-parse Source into OriginalAst whose spans ARE anchored to
80	// Source bytes. Walk both ASTs in parallel, collecting (removed-let-span,
81	// substituted-ident-span, replacement-text) triples, then apply them.
82	if let Some(Patched) = TryPatchSource(Source, &Ast) {
83		return Ok(Some(Patched));
84	}
85
86	// Fallback: full reprint via prettyplease (drops comments / whitespace).
87	Ok(Some(prettyplease::unparse(&Ast)))
88}
89
90// ---------------------------------------------------------------------------
91// Span-based patch path
92// ---------------------------------------------------------------------------
93
94/// Attempt to reconstruct the output by splicing only the changed ranges.
95/// Returns None on any span resolution failure, triggering the fallback.
96fn TryPatchSource(Source:&str, MutatedAst:&syn::File) -> Option<String> {
97	// Re-parse Source so we have an AST whose Spans are anchored to Source.
98	let OriginalAst:syn::File = syn::parse_str(Source).ok()?;
99
100	let mut Patches:Vec<Patch::Patch> = Vec::new();
101
102	// Walk items in parallel. We only care about function bodies; other items
103	// (mod, use, struct, ...) are never touched by the eliminator.
104	for (OrigItem, MutItem) in OriginalAst.items.iter().zip(MutatedAst.items.iter()) {
105		if let (syn::Item::Fn(OrigFn), syn::Item::Fn(MutFn)) = (OrigItem, MutItem) {
106			CollectBlockPatches(&OrigFn.block, &MutFn.block, Source, &mut Patches)?;
107		}
108	}
109
110	if Patches.is_empty() {
111		// No spans resolved but AnyChanged was true - fall back.
112		return None;
113	}
114
115	// Sort by start offset and verify non-overlapping.
116	Patches.sort_by_key(|P| P.Start);
117
118	Patch::ApplyPatches(Source, &Patches)
119}
120
121/// Recursively compare OrigBlock (spans anchored to Source) and MutBlock
122/// (mutated AST, spans may be synthetic) and collect patches for any
123/// statements that differ.
124fn CollectBlockPatches(
125	OrigBlock:&syn::Block,
126
127	MutBlock:&syn::Block,
128
129	Source:&str,
130
131	Patches:&mut Vec<Patch::Patch>,
132) -> Option<()> {
133	// Walk MutBlock statements; for each one, find the corresponding
134	// statement(s) in OrigBlock by matching on identity (same kind, same
135	// initialiser text for lets). Statements the eliminator REMOVED will
136	// be present in OrigBlock but absent from MutBlock. Statements where
137	// an identifier was SUBSTITUTED will have a different token stream.
138
139	let mut OrigIdx = 0usize;
140
141	for MutStmt in &MutBlock.stmts {
142		// Advance OrigIdx until we find a statement that matches MutStmt
143		// or we exhaust OrigBlock.
144		while OrigIdx < OrigBlock.stmts.len() {
145			let OrigStmt = &OrigBlock.stmts[OrigIdx];
146
147			OrigIdx += 1;
148
149			if StmtTokensMatch(OrigStmt, MutStmt) {
150				// Same statement - no patch needed here.
151				break;
152			}
153
154			// OrigStmt is in the original but not in MutBlock at this
155			// position: it was a removed let. Emit a delete patch.
156			let (Start, End) = Patch::StmtLineRange(OrigStmt, Source)?;
157
158			Patches.push(Patch::Patch { Start, End, Replacement:String::new() });
159
160			// Now check if MutStmt corresponds to this OrigStmt after the
161			// removal - if not, continue consuming OrigBlock.
162			if StmtTokensMatch(&OrigBlock.stmts[OrigIdx - 1 + 1], MutStmt) {
163				break;
164			}
165		}
166
167		// Recurse into inner blocks.
168		CollectInnerBlockPatches(MutStmt, Source, Patches)?;
169	}
170
171	Some(())
172}
173
174/// Recurse into block-containing expression variants.
175fn CollectInnerBlockPatches(Stmt:&syn::Stmt, Source:&str, Patches:&mut Vec<Patch::Patch>) -> Option<()> {
176	use syn::{Expr, Stmt as S};
177
178	match Stmt {
179		S::Expr(Expr::Block(B), _) => {
180			for S_ in &B.block.stmts {
181				CollectInnerBlockPatches(S_, Source, Patches)?;
182			}
183		},
184
185		_ => {},
186	}
187
188	Some(())
189}
190
191/// Compare two statements by their token stream text.
192fn StmtTokensMatch(A:&syn::Stmt, B:&syn::Stmt) -> bool {
193	use quote::ToTokens;
194
195	let mut Ta = proc_macro2::TokenStream::new();
196
197	let mut Tb = proc_macro2::TokenStream::new();
198
199	A.to_tokens(&mut Ta);
200
201	B.to_tokens(&mut Tb);
202
203	Ta.to_string() == Tb.to_string()
204}
205
206// ---------------------------------------------------------------------------
207// Preserve-layout entry point - span-free text substitution
208// ---------------------------------------------------------------------------
209
210/// Identify inlinable bindings via the same AST pipeline as `Run`, but apply
211/// the substitutions as targeted text edits so that every character outside
212/// the affected `let` binding and its single use-site is preserved verbatim.
213///
214/// Uses no `proc_macro2` span APIs; works on stable Rust with the dependency
215/// set already declared in `Cargo.toml`.
216///
217/// Returns `Ok(None)` when no bindings were eliminated.
218pub fn RunPreserve(Source:&str, Options:&Definition::Options) -> Error::Result<Option<String>> {
219	let mut Working = Source.to_owned();
220
221	let mut AnyChanged = false;
222
223	for _ in 0..super::Constant::MaxIterations {
224		match PreservePass(&Working, Options)? {
225			Some(Next) => {
226				Working = Next;
227
228				AnyChanged = true;
229			},
230
231			None => break,
232		}
233	}
234
235	if AnyChanged { Ok(Some(Working)) } else { Ok(None) }
236}
237
238/// One pass: parse `Working`, find the first inlinable binding, apply the
239/// text edit, return `Some(new_text)`. Returns `None` when nothing changed.
240fn PreservePass(Working:&str, Options:&Definition::Options) -> Error::Result<Option<String>> {
241	let Ast:syn::File = syn::parse_str(Working).map_err(|E| Error::Error::Parse { Path:String::new(), Source:E })?;
242
243	for Item in &Ast.items {
244		if let Some(Result) = TryItemPreserve(Item, Working, Options)? {
245			return Ok(Some(Result));
246		}
247	}
248
249	Ok(None)
250}
251
252fn TryItemPreserve(Item:&syn::Item, Working:&str, Options:&Definition::Options) -> Error::Result<Option<String>> {
253	match Item {
254		syn::Item::Fn(F) => TryBlockPreserve(&F.block, Working, Options),
255
256		syn::Item::Impl(I) => {
257			for ImplItem in &I.items {
258				if let syn::ImplItem::Fn(M) = ImplItem {
259					if let Some(R) = TryBlockPreserve(&M.block, Working, Options)? {
260						return Ok(Some(R));
261					}
262				}
263			}
264
265			Ok(None)
266		},
267
268		_ => Ok(None),
269	}
270}
271
272fn TryBlockPreserve(Block:&syn::Block, Working:&str, Options:&Definition::Options) -> Error::Result<Option<String>> {
273	let Candidates = Collect::Collect(Block, Options.InlineComments);
274
275	for Candidate in &Candidates {
276		if !Safe::IsSafe(&Candidate.Init, Options.MaxSize) {
277			continue;
278		}
279
280		let (RefCount, InClosure, InLoop) =
281			Count::CountReferences(&Candidate.Ident, &Block.stmts[Candidate.StmtIndex + 1..]);
282
283		if RefCount != 1 || InClosure || InLoop {
284			continue;
285		}
286
287		let StmtsAfter = &Block.stmts[Candidate.StmtIndex + 1..];
288
289		let SubstSiteOffset = Inline::FindSubstSite(StmtsAfter, &Candidate.Ident);
290
291		if !Safe::IsFreeVarSafe(&Candidate.Init, &StmtsAfter[..SubstSiteOffset]) {
292			continue;
293		}
294
295		// Clone downstream statements; substitute in-memory.
296		let mut UseStmts:Vec<syn::Stmt> = Block.stmts[Candidate.StmtIndex + 1..].to_vec();
297
298		if !Inline::SubstituteRef(&mut UseStmts, &Candidate.Ident, &Candidate.Init) {
299			continue;
300		}
301
302		// Render the ORIGINAL let-stmt and use-stmt to canonical text so we
303		// can locate them in `Working` by plain string search.
304		let LetText = StmtToText(&Block.stmts[Candidate.StmtIndex]);
305
306		let UseOrigText = StmtToText(&Block.stmts[Candidate.StmtIndex + 1 + SubstSiteOffset]);
307
308		let UseNewText = StmtToText(&UseStmts[SubstSiteOffset]);
309
310		// Locate the original let-stmt text in Working.
311		let Some(LetPos) = Working.find(&LetText) else {
312			continue;
313		};
314
315		// Locate the original use-stmt text - must appear AFTER the let.
316		let SearchFrom = LetPos + LetText.len();
317
318		let Some(UseOffset) = Working[SearchFrom..].find(&UseOrigText) else {
319			continue;
320		};
321
322		let UsePos = SearchFrom + UseOffset;
323
324		// Apply edits in reverse order (use comes later, so edit it first so
325		// the let-stmt byte positions remain valid).
326		let mut Out = Working.to_owned();
327
328		// 1. Replace the use-stmt with the substituted version.
329		Out.replace_range(UsePos..UsePos + UseOrigText.len(), &UseNewText);
330
331		// 2. Remove the let-stmt line (expand to include trailing newline).
332		let LetEnd = LetPos + LetText.len();
333
334		let ExpandedLetEnd = if LetEnd < Out.len() && Out.as_bytes()[LetEnd] == b'\n' {
335			LetEnd + 1
336		} else {
337			LetEnd
338		};
339
340		Out.replace_range(LetPos..ExpandedLetEnd, "");
341
342		return Ok(Some(Out));
343	}
344
345	// Recurse into directly nested blocks.
346	for Stmt in &Block.stmts {
347		if let Some(Nested) = StmtNestedBlock(Stmt) {
348			if let Some(R) = TryBlockPreserve(Nested, Working, Options)? {
349				return Ok(Some(R));
350			}
351		}
352	}
353
354	Ok(None)
355}
356
357// ---------------------------------------------------------------------------
358// Helpers
359// ---------------------------------------------------------------------------
360
361/// Render a single `syn::Stmt` to its canonical text representation by
362/// wrapping it in a dummy function body and extracting the inner line(s).
363/// The wrapper indentation (one tab or 4 spaces from prettyplease) is
364/// stripped so the result is indentation-relative.
365fn StmtToText(Stmt:&syn::Stmt) -> String {
366	use quote::quote;
367
368	let Wrapped:syn::File = syn::parse_quote! { fn __d() { #Stmt } };
369
370	let Full = prettyplease::unparse(&Wrapped);
371
372	// Full looks like "fn __d() {\n    <stmt>\n}\n".
373	// Extract between first '{' and last '}'.
374	if let (Some(Open), Some(Close)) = (Full.find('{'), Full.rfind('}')) {
375		let Inner = Full[Open + 1..Close].trim_matches('\n');
376
377		return Inner
378			.lines()
379			.map(|L| L.strip_prefix('\t').or_else(|| L.strip_prefix("    ")).unwrap_or(L))
380			.collect::<Vec<_>>()
381			.join("\n");
382	}
383
384	Full
385}
386
387/// Extract a directly nested `Block` from a statement for recursion.
388fn StmtNestedBlock(Stmt:&syn::Stmt) -> Option<&syn::Block> {
389	if let syn::Stmt::Expr(Expr, _) = Stmt {
390		match Expr {
391			syn::Expr::Block(B) => return Some(&B.block),
392
393			syn::Expr::If(I) => return Some(&I.then_branch),
394
395			syn::Expr::Loop(L) => return Some(&L.body),
396
397			syn::Expr::While(W) => return Some(&W.body),
398
399			syn::Expr::ForLoop(F) => return Some(&F.body),
400
401			syn::Expr::Unsafe(U) => return Some(&U.block),
402
403			_ => {},
404		}
405	}
406
407	None
408}