diff --git a/Source/Eliminate/CLI.rs b/Source/Eliminate/CLI.rs index 7c8303d..63898ea 100644 --- a/Source/Eliminate/CLI.rs +++ b/Source/Eliminate/CLI.rs @@ -46,6 +46,12 @@ pub struct Cli { /// Print a line for every binding that is inlined. #[clap(long, short = 'v')] pub Verbose:bool, + + /// Reformat the entire file with prettyplease after inlining. + /// Default: only the inlined binding sites are rewritten; comments, + /// blank lines, and indentation are preserved verbatim. + #[clap(long, short = 'r')] + pub Reformat:bool, } impl Cli { @@ -55,6 +61,7 @@ impl Cli { InlineComments:self.InlineComments, DryRun:self.DryRun, Verbose:self.Verbose, + Reformat:self.Reformat, }; let Stats = Process::Process(&self.Path, &self.Glob, &Options)?; diff --git a/Source/Eliminate/Definition.rs b/Source/Eliminate/Definition.rs index d1d9f35..7c1d0b1 100644 --- a/Source/Eliminate/Definition.rs +++ b/Source/Eliminate/Definition.rs @@ -23,6 +23,14 @@ pub struct Options { /// When `true`, emit per-binding log lines. pub Verbose:bool, + + /// When `true`, reformat the entire file with `prettyplease` after + /// inlining (the previous default behaviour). + /// + /// Default `false` - only the inlined binding sites are rewritten; + /// all comments, blank lines, section banners, and the original + /// indentation style are preserved verbatim. + pub Reformat:bool, } impl Default for Options { @@ -32,6 +40,7 @@ impl Default for Options { InlineComments:false, DryRun:false, Verbose:false, + Reformat:false, } } } diff --git a/Source/Eliminate/Process.rs b/Source/Eliminate/Process.rs index b3df2f2..489ee5a 100644 --- a/Source/Eliminate/Process.rs +++ b/Source/Eliminate/Process.rs @@ -18,6 +18,13 @@ use super::{Definition, Error, Transform}; /// elimination transform on each, and write back the result unless /// `Options.DryRun` is set. /// +/// When `Options.Reformat` is `false` (the default) the preserve-layout path +/// is used: only the inlined binding sites are rewritten; comments, blank +/// lines, and indentation style are kept verbatim. +/// +/// When `Options.Reformat` is `true` the whole file is reformatted with +/// `prettyplease` after inlining (the previous unconditional behaviour). +/// /// Returns aggregate [`Definition::Stats`] describing what was processed. pub fn Process(Root:&Path, Pattern:&str, Options:&Definition::Options) -> Error::Result { let mut Stats = Definition::Stats::default(); @@ -69,8 +76,16 @@ fn CollectFiles(Root:&Path, GlobMatcher:&GlobSet) -> Vec { fn ProcessFile(FilePath:&Path, Options:&Definition::Options, Stats:&mut Definition::Stats) -> Error::Result<()> { let Source = fs::read_to_string(FilePath)?; - let TransformResult = Transform::Run(&Source, Options).map_err(|E| { - if let Error::Error::Parse { Source: Src, .. } = E { + // Choose the transform path based on Options.Reformat. + // - Reformat:false (default): text-level substitution, layout preserved. + // - Reformat:true: full prettyplease reformat (previous behaviour). + let TransformResult = if Options.Reformat { + Transform::Run(&Source, Options) + } else { + Transform::RunPreserve(&Source, Options) + } + .map_err(|E| { + if let Error::Error::Parse { Source:Src, .. } = E { Error::Error::Parse { Path:FilePath.display().to_string(), Source:Src } } else { E diff --git a/Source/Eliminate/Transform/mod.rs b/Source/Eliminate/Transform/mod.rs index cc66158..a3ae4df 100644 --- a/Source/Eliminate/Transform/mod.rs +++ b/Source/Eliminate/Transform/mod.rs @@ -3,22 +3,26 @@ //=============================================================================// // Module: Transform - AST transformation pipeline // -// Entry point: Run(source, options) parses the Rust source with syn, applies -// iterative single-use-variable elimination, then attempts to reconstruct the -// output by splicing only the changed byte ranges back into the original source -// text (preserving inline comments, blank lines, and all formatting trivia). +// Two entry points: // -// Patch path (preferred): -// For each eliminated binding, Patch locates the byte span of the let -// statement and the byte span of the substitution site in the original source -// text via proc_macro2 Span offsets, and applies those as sorted -// non-overlapping replacements. The rest of the file is copied verbatim. +// Run(source, options) +// Original behaviour: parse with syn, run the VisitMut eliminator, +// then attempt span-based text patching to preserve comments and +// whitespace. Falls back to prettyplease::unparse when span data is +// unavailable. Used by the Reformat path and by all existing unit tests +// in Inline.rs (which compare output against prettyplease-normalised +// expected values). // -// Fallback path: -// When span information is unavailable (proc_macro2 built without -// span-locations, or any span offset resolves to None), the pipeline falls -// back to prettyplease::unparse. This keeps behaviour no worse than before -// for environments where span data is stripped. +// RunPreserve(source, options) +// Preserve-layout behaviour (default when Options.Reformat == false): +// identifies inlinable bindings via the same Collect/Safe/Count pipeline, +// then applies targeted text substitutions to the original source string +// without touching anything outside the affected lines. Comments, blank +// lines, section banners, and the original indentation style survive +// unchanged. Uses NO proc_macro2 span APIs so no extra Cargo features +// are required. +// +// Returns `Ok(None)` when no bindings were eliminated. //=============================================================================// pub mod Collect; @@ -29,11 +33,23 @@ pub mod Safe; use super::{Definition, Error}; -/// Parse Source, run up to MaxIterations elimination passes, then return the -/// patched source text. Returns Ok(None) when no bindings were eliminated. +// --------------------------------------------------------------------------- +// Original entry point - span-based patch path with prettyplease fallback +// (signature identical to Current; all Inline.rs tests call this function) +// --------------------------------------------------------------------------- + +/// Parse `Source`, run up to [`super::Constant::MaxIterations`] elimination +/// passes, then return the patched source text. +/// +/// Preferred path: span-based text patching via `TryPatchSource` preserves +/// inline comments and blank lines. Falls back to `prettyplease::unparse` +/// when span-location data is unavailable. +/// +/// Returns `Ok(None)` when no bindings were eliminated (caller can skip the +/// write-back). pub fn Run(Source:&str, Options:&Definition::Options) -> Error::Result> { - let mut Ast:syn::File = - syn::parse_str(Source).map_err(|E| Error::Error::Parse { Path:String::new(), Source:E })?; + let mut Ast:syn::File = syn::parse_str(Source) + .map_err(|E| Error::Error::Parse { Path:String::new(), Source:E })?; let mut AnyChanged = false; @@ -183,186 +199,192 @@ fn StmtTokensMatch(A:&syn::Stmt, B:&syn::Stmt) -> bool { } // --------------------------------------------------------------------------- -// Preserve-layout path (RunPreserve) +// Preserve-layout entry point - span-free text substitution // --------------------------------------------------------------------------- -/// Preserve-layout variant: inline single-use bindings with minimal textual -/// rewriting. Only the `let` line and its single use-site are changed; -/// all comments, blank lines, section banners, and original indentation are -/// kept verbatim. +/// Identify inlinable bindings via the same AST pipeline as `Run`, but apply +/// the substitutions as targeted text edits so that every character outside +/// the affected `let` binding and its single use-site is preserved verbatim. /// -/// Uses `prettyplease` only to render individual expressions to text, never -/// to reformat the whole file. +/// Uses no `proc_macro2` span APIs; works on stable Rust with the dependency +/// set already declared in `Cargo.toml`. /// /// Returns `Ok(None)` when no bindings were eliminated. pub fn RunPreserve(Source:&str, Options:&Definition::Options) -> Error::Result> { - use std::fmt::Write as _; - - /// Render a `syn::Expr` to canonical text via prettyplease by wrapping it - /// in a throwaway function body, pretty-printing, then stripping the - /// wrapper. This avoids any span/proc-macro2 feature flags. - fn ExprText(E:&syn::Expr) -> Option { - let Dummy = format!("fn __d() {{ let __v = {}; }}", quote::quote!(#E)); + let mut Working = Source.to_owned(); + let mut AnyChanged = false; - let Ast:syn::File = syn::parse_str(&Dummy).ok()?; + for _ in 0..super::Constant::MaxIterations { + match PreservePass(&Working, Options)? { + Some(Next) => { + Working = Next; + AnyChanged = true; + }, - let Pretty = prettyplease::unparse(&Ast); + None => break, + } + } - // Extract the initialiser from ` let __v = ;\n` - let Start = Pretty.find("let __v = ")? + "let __v = ".len(); + if AnyChanged { Ok(Some(Working)) } else { Ok(None) } +} - let End = Pretty[Start..].find(';').map(|I| Start + I)?; +/// One pass: parse `Working`, find the first inlinable binding, apply the +/// text edit, return `Some(new_text)`. Returns `None` when nothing changed. +fn PreservePass(Working:&str, Options:&Definition::Options) -> Error::Result> { + let Ast:syn::File = syn::parse_str(Working) + .map_err(|E| Error::Error::Parse { Path:String::new(), Source:E })?; - Some(Pretty[Start..End].trim().to_string()) + for Item in &Ast.items { + if let Some(Result) = TryItemPreserve(Item, Working, Options)? { + return Ok(Some(Result)); + } } - /// Render a `let = ;` binding to canonical text the same way. - fn LetText(Ident:&str, E:&syn::Expr) -> Option { - let Dummy = format!("fn __d() {{ let {} = {}; }}", Ident, quote::quote!(#E)); - - let Ast:syn::File = syn::parse_str(&Dummy).ok()?; - - let Pretty = prettyplease::unparse(&Ast); + Ok(None) +} - let Marker = format!("let {} = ", Ident); +fn TryItemPreserve( + Item:&syn::Item, + Working:&str, + Options:&Definition::Options, +) -> Error::Result> { + match Item { + syn::Item::Fn(F) => TryBlockPreserve(&F.block, Working, Options), - let Start = Pretty.find(&Marker)?; + syn::Item::Impl(I) => { + for ImplItem in &I.items { + if let syn::ImplItem::Fn(M) = ImplItem { + if let Some(R) = TryBlockPreserve(&M.block, Working, Options)? { + return Ok(Some(R)); + } + } + } - let End = Pretty[Start..].find(';').map(|I| Start + I + 1)?; + Ok(None) + }, - Some(Pretty[Start..End].trim().to_string()) + _ => Ok(None), } +} - let mut Working = Source.to_string(); - - let mut AnyChanged = false; - - 'outer: loop { - let Ast:syn::File = syn::parse_str(&Working) - .map_err(|E| Error::Error::Parse { Path:String::new(), Source:E })?; - - // Walk every function body looking for single-use let bindings. - for Item in &Ast.items { - let Blocks = CollectBlocks(Item); +fn TryBlockPreserve( + Block:&syn::Block, + Working:&str, + Options:&Definition::Options, +) -> Error::Result> { + let Candidates = Collect::Collect(Block, Options.InlineComments); - for Block in Blocks { - let Candidates = super::Transform::Collect::Collect(Block, Options.InlineComments); + for Candidate in &Candidates { + if !Safe::IsSafe(&Candidate.Init, Options.MaxSize) { + continue; + } - for Candidate in &Candidates { - if !super::Transform::Safe::IsSafe(&Candidate.Init, Options.MaxSize) { - continue; - } + let (RefCount, InClosure, InLoop) = + Count::CountReferences(&Candidate.Ident, &Block.stmts[Candidate.StmtIndex + 1..]); - let (RefCount, InClosure, InLoop) = super::Transform::Count::CountReferences( - &Candidate.Ident, - &Block.stmts[Candidate.StmtIndex + 1..], - ); + if RefCount != 1 || InClosure || InLoop { + continue; + } - if RefCount != 1 || InClosure || InLoop { - continue; - } + // Clone downstream statements; substitute in-memory. + let mut UseStmts:Vec = Block.stmts[Candidate.StmtIndex + 1..].to_vec(); - let LetStr = match LetText(&Candidate.Ident, &Candidate.Init) { - Some(S) => S, - None => continue, - }; + if !Inline::SubstituteRef(&mut UseStmts, &Candidate.Ident, &Candidate.Init) { + continue; + } - let InitStr = match ExprText(&Candidate.Init) { - Some(S) => S, - None => continue, - }; + // Render the ORIGINAL let-stmt and use-stmt to canonical text so we + // can locate them in `Working` by plain string search. + let LetText = StmtToText(&Block.stmts[Candidate.StmtIndex]); + let UseOrigText = StmtToText(&Block.stmts[Candidate.StmtIndex + 1]); + let UseNewText = StmtToText(&UseStmts[0]); - let IdentStr = &Candidate.Ident; + // Locate the original let-stmt text in Working. + let Some(LetPos) = Working.find(&LetText) else { + continue; + }; - // Find and remove the let line, then replace the use-site. - if let Some(LetPos) = Working.find(&LetStr) { - // Find the full line span (including leading whitespace + trailing newline). - let LineStart = Working[..LetPos].rfind('\n').map(|I| I + 1).unwrap_or(0); + // Locate the original use-stmt text - must appear AFTER the let. + let SearchFrom = LetPos + LetText.len(); + let Some(UseOffset) = Working[SearchFrom..].find(&UseOrigText) else { + continue; + }; - let LineEnd = Working[LetPos..] - .find('\n') - .map(|I| LetPos + I + 1) - .unwrap_or(Working.len()); + let UsePos = SearchFrom + UseOffset; - // Find the use-site of the identifier after the let line. - let SearchFrom = LineEnd; + // Apply edits in reverse order (use comes later, so edit it first so + // the let-stmt byte positions remain valid). + let mut Out = Working.to_owned(); - if let Some(RelPos) = find_word(&Working[SearchFrom..], IdentStr) { - let UsePos = SearchFrom + RelPos; - let UseEnd = UsePos + IdentStr.len(); + // 1. Replace the use-stmt with the substituted version. + Out.replace_range(UsePos..UsePos + UseOrigText.len(), &UseNewText); - // Replace use-site first (later in file, so offsets of let line unaffected). - Working.replace_range(UsePos..UseEnd, &InitStr); + // 2. Remove the let-stmt line (expand to include trailing newline). + let LetEnd = LetPos + LetText.len(); + let ExpandedLetEnd = if LetEnd < Out.len() && Out.as_bytes()[LetEnd] == b'\n' { + LetEnd + 1 + } else { + LetEnd + }; - // Now remove the let line. - Working.replace_range(LineStart..LineEnd, ""); + Out.replace_range(LetPos..ExpandedLetEnd, ""); - AnyChanged = true; + return Ok(Some(Out)); + } - continue 'outer; - } - } - } + // Recurse into directly nested blocks. + for Stmt in &Block.stmts { + if let Some(Nested) = StmtNestedBlock(Stmt) { + if let Some(R) = TryBlockPreserve(Nested, Working, Options)? { + return Ok(Some(R)); } } - - // No more candidates found in this pass. - break; } - if AnyChanged { Ok(Some(Working)) } else { Ok(None) } + Ok(None) } // --------------------------------------------------------------------------- -// Helpers for RunPreserve +// Helpers // --------------------------------------------------------------------------- -/// Word-boundary-aware substring search: returns the byte offset of the first -/// occurrence of `Word` in `Haystack` where the match is not immediately -/// preceded or followed by an alphanumeric character or underscore. -fn find_word(Haystack:&str, Word:&str) -> Option { - let Bytes = Haystack.as_bytes(); - let Pat = Word.as_bytes(); - - let mut Pos = 0usize; - - while Pos + Pat.len() <= Bytes.len() { - if Bytes[Pos..].starts_with(Pat) { - let Before = Pos > 0 && (Bytes[Pos - 1].is_ascii_alphanumeric() || Bytes[Pos - 1] == b'_'); - - let After = Bytes - .get(Pos + Pat.len()) - .map_or(false, |&B| B.is_ascii_alphanumeric() || B == b'_'); - - if !Before && !After { - return Some(Pos); - } - } - - Pos += 1; +/// Render a single `syn::Stmt` to its canonical text representation by +/// wrapping it in a dummy function body and extracting the inner line(s). +/// The wrapper indentation (one tab or 4 spaces from prettyplease) is +/// stripped so the result is indentation-relative. +fn StmtToText(Stmt:&syn::Stmt) -> String { + use quote::quote; + + let Wrapped:syn::File = syn::parse_quote! { fn __d() { #Stmt } }; + let Full = prettyplease::unparse(&Wrapped); + + // Full looks like "fn __d() {\n \n}\n". + // Extract between first '{' and last '}'. + if let (Some(Open), Some(Close)) = (Full.find('{'), Full.rfind('}')) { + let Inner = Full[Open + 1..Close].trim_matches('\n'); + + return Inner + .lines() + .map(|L| L.strip_prefix('\t').or_else(|| L.strip_prefix(" ")).unwrap_or(L)) + .collect::>() + .join("\n"); } - None + Full } -/// Collect all `syn::Block` references reachable from a top-level `Item`. -/// Only descends into function bodies (free functions and impl methods). -fn CollectBlocks(Item:&syn::Item) -> Vec<&syn::Block> { - let mut Out = Vec::new(); - - match Item { - syn::Item::Fn(F) => Out.push(F.block.as_ref()), - - syn::Item::Impl(Impl) => { - for ImplItem in &Impl.items { - if let syn::ImplItem::Fn(M) = ImplItem { - Out.push(&M.block); - } - } - }, - - _ => {}, +/// Extract a directly nested `Block` from a statement for recursion. +fn StmtNestedBlock(Stmt:&syn::Stmt) -> Option<&syn::Block> { + if let syn::Stmt::Expr(Expr, _) = Stmt { + match Expr { + syn::Expr::Block(B) => return Some(&B.block), + syn::Expr::If(I) => return Some(&I.then_branch), + syn::Expr::Loop(L) => return Some(&L.body), + syn::Expr::While(W) => return Some(&W.body), + syn::Expr::ForLoop(F) => return Some(&F.body), + syn::Expr::Unsafe(U) => return Some(&U.block), + _ => {}, + } } - - Out + None }