@@ -3,12 +3,16 @@ use crate::check::{
33 Parsed ,
44} ;
55use regex:: Regex ;
6- use std:: sync:: LazyLock ;
6+ use std:: { collections :: HashSet , sync:: LazyLock } ;
77
88// Regex to match import statements with symbol lists: `import {Symbol1, Symbol2} from "...";`
99static RE_IMPORT_SYMBOL_LIST : LazyLock < Regex > =
1010 LazyLock :: new ( || Regex :: new ( r#"import\s*\{([^}]+)\}\s+from\s+"[^"]+";"# ) . unwrap ( ) ) ;
1111
12+ // Same but with path captured for fix_source (reconstructing the statement).
13+ static RE_IMPORT_SYMBOL_LIST_WITH_PATH : LazyLock < Regex > =
14+ LazyLock :: new ( || Regex :: new ( r#"import\s*\{([^}]+)\}\s+from\s+"([^"]+)";"# ) . unwrap ( ) ) ;
15+
1216// Regex to match aliased imports: `import "..." as Alias;`
1317static RE_IMPORT_ALIAS : LazyLock < Regex > =
1418 LazyLock :: new ( || Regex :: new ( r#"import\s+"[^"]+"\s+as\s+(\w+);"# ) . unwrap ( ) ) ;
@@ -141,10 +145,96 @@ fn is_symbol_used_excluding_imports(
141145 false
142146}
143147
148+ /// Returns the source with unused imports removed, or `None` if no changes.
149+ ///
150+ /// - `only_remove`: if `Some(set)`, only remove symbols in the set (e.g. fixable from report). If
151+ /// `None`, remove all unused imports.
152+ ///
153+ /// # Panics
154+ ///
155+ /// Panics if a regex capture group is missing (should not happen with the current patterns).
156+ #[ must_use]
157+ #[ allow( clippy:: implicit_hasher) ]
158+ pub fn fix_source ( parsed : & Parsed , only_remove : Option < & HashSet < String > > ) -> Option < String > {
159+ let mut import_ranges: Vec < ( usize , usize ) > = Vec :: new ( ) ;
160+ for cap in RE_IMPORT_SYMBOL_LIST_WITH_PATH . captures_iter ( & parsed. src ) {
161+ let m = cap. get ( 0 ) . expect ( "capture 0 always present" ) ;
162+ import_ranges. push ( ( m. start ( ) , m. end ( ) ) ) ;
163+ }
164+ for cap in RE_IMPORT_ALIAS . captures_iter ( & parsed. src ) {
165+ let m = cap. get ( 0 ) . expect ( "capture 0 always present" ) ;
166+ import_ranges. push ( ( m. start ( ) , m. end ( ) ) ) ;
167+ }
168+
169+ let mut edits: Vec < ( usize , usize , String ) > = Vec :: new ( ) ;
170+
171+ // Named imports: `import { A, B } from "path";`
172+ for cap in RE_IMPORT_SYMBOL_LIST_WITH_PATH . captures_iter ( & parsed. src ) {
173+ let m = cap. get ( 0 ) . expect ( "capture 0 always present" ) ;
174+ let start = m. start ( ) ;
175+ let end = m. end ( ) ;
176+ let symbols_str = cap. get ( 1 ) . expect ( "capture 1 always present" ) . as_str ( ) ;
177+ let path = cap. get ( 2 ) . expect ( "capture 2 always present" ) . as_str ( ) ;
178+
179+ let mut kept: Vec < & str > = Vec :: new ( ) ;
180+ for symbol_part in symbols_str. split ( ',' ) {
181+ let symbol_part = symbol_part. trim ( ) ;
182+ let name =
183+ symbol_part. split_once ( " as " ) . map_or ( symbol_part, |( _, alias) | alias. trim ( ) ) ;
184+ let should_remove = only_remove. map_or_else (
185+ || !is_symbol_used_excluding_imports ( & parsed. src , name, & import_ranges) ,
186+ |set| set. contains ( name) ,
187+ ) ;
188+ if !should_remove {
189+ kept. push ( symbol_part) ;
190+ }
191+ }
192+
193+ if kept. is_empty ( ) {
194+ edits. push ( ( start, end, String :: new ( ) ) ) ;
195+ } else if kept. len ( ) < symbol_part_count ( symbols_str) {
196+ let new_list = kept. join ( ", " ) ;
197+ edits. push ( ( start, end, format ! ( r#"import {{ {new_list} }} from "{path}";"# ) ) ) ;
198+ }
199+ }
200+
201+ // Aliased imports: `import "..." as Alias;`
202+ for cap in RE_IMPORT_ALIAS . captures_iter ( & parsed. src ) {
203+ let m = cap. get ( 0 ) . expect ( "capture 0 always present" ) ;
204+ let start = m. start ( ) ;
205+ let end = m. end ( ) ;
206+ let alias = cap. get ( 1 ) . expect ( "capture 1 always present" ) . as_str ( ) ;
207+ let should_remove = only_remove. map_or_else (
208+ || !is_symbol_used_excluding_imports ( & parsed. src , alias, & import_ranges) ,
209+ |set| set. contains ( alias) ,
210+ ) ;
211+ if should_remove {
212+ edits. push ( ( start, end, String :: new ( ) ) ) ;
213+ }
214+ }
215+
216+ if edits. is_empty ( ) {
217+ return None ;
218+ }
219+
220+ // Apply from end to start so offsets stay valid.
221+ edits. sort_by_key ( |( s, _e, _r) | std:: cmp:: Reverse ( * s) ) ;
222+ let mut out = parsed. src . clone ( ) ;
223+ for ( start, end, replacement) in edits {
224+ out = format ! ( "{}{}{}" , & out[ ..start] , replacement, & out[ end..] ) ;
225+ }
226+ Some ( out)
227+ }
228+
229+ fn symbol_part_count ( symbols_str : & str ) -> usize {
230+ symbols_str. split ( ',' ) . filter ( |s| !s. trim ( ) . is_empty ( ) ) . count ( )
231+ }
232+
144233#[ cfg( test) ]
145234mod tests {
146235 use super :: * ;
147236 use crate :: check:: utils:: ExpectedFindings ;
237+ use itertools:: Itertools ;
148238
149239 #[ test]
150240 fn test_no_unused_imports ( ) {
@@ -262,4 +352,70 @@ mod tests {
262352 } ;
263353 expected_findings. assert_eq ( content, & validate) ;
264354 }
355+
356+ fn parsed_from_src ( content : & str ) -> crate :: check:: Parsed {
357+ use crate :: check:: { comments:: Comments , inline_config:: InlineConfig } ;
358+ use std:: path:: PathBuf ;
359+
360+ let ( pt, comments) = crate :: parser:: parse_solidity ( content, 0 ) . expect ( "parse" ) ;
361+ let comments = Comments :: new ( comments, content) ;
362+ let ( inline_config_items, invalid_inline_config_items) : ( Vec < _ > , Vec < _ > ) =
363+ comments. parse_inline_config_items ( ) . partition_result ( ) ;
364+ let inline_config = InlineConfig :: new ( inline_config_items, content) ;
365+ crate :: check:: Parsed {
366+ file : PathBuf :: from ( "./src/Contract.sol" ) ,
367+ src : content. to_string ( ) ,
368+ pt,
369+ comments,
370+ inline_config,
371+ invalid_inline_config_items,
372+ file_config : crate :: check:: file_config:: FileConfig :: default ( ) ,
373+ path_config : crate :: foundry_config:: CheckPaths :: default ( ) ,
374+ }
375+ }
376+
377+ #[ test]
378+ fn test_fix_source_removes_unused_from_named_import ( ) {
379+ let content = r#"import {ERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
380+
381+ contract MyContract {
382+ ERC20 public token;
383+ }
384+ "# ;
385+ let parsed = parsed_from_src ( content) ;
386+ let fixed = fix_source ( & parsed, None ) . unwrap ( ) ;
387+ assert ! (
388+ fixed. contains(
389+ r#"import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";"#
390+ ) ,
391+ "expected single used symbol in import, got: {fixed:?}"
392+ ) ;
393+ assert ! ( !fixed. contains( "IERC20" ) ) ;
394+ }
395+
396+ #[ test]
397+ fn test_fix_source_removes_whole_aliased_import ( ) {
398+ let content = r#"import "@openzeppelin/contracts/token/ERC20/ERC20.sol" as OZERC20;
399+
400+ contract MyContract {
401+ }
402+ "# ;
403+ let parsed = parsed_from_src ( content) ;
404+ let fixed = fix_source ( & parsed, None ) . unwrap ( ) ;
405+ assert ! ( !fixed. contains( "OZERC20" ) ) ;
406+ assert ! ( !fixed. contains( "as OZERC20" ) ) ;
407+ }
408+
409+ #[ test]
410+ fn test_fix_source_no_change_when_all_used ( ) {
411+ let content = r#"import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
412+
413+ contract MyContract {
414+ ERC20 public token;
415+ }
416+ "# ;
417+ let parsed = parsed_from_src ( content) ;
418+ let fixed = fix_source ( & parsed, None ) ;
419+ assert ! ( fixed. is_none( ) ) ;
420+ }
265421}
0 commit comments