Skip to content

Commit de5d59a

Browse files
feat: add scopelint fix (#60)
1 parent 9bb977b commit de5d59a

10 files changed

Lines changed: 312 additions & 2 deletions

File tree

DEV.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,8 @@ The project uses GitHub Actions for continuous integration:
160160
## Development Workflow
161161

162162
**Command Summary:**
163+
- `scopelint check` - Run convention checks
164+
- `scopelint fix` - Apply safe fixes (e.g. remove unused imports), then run check
163165
- `scopelint-dev` - Local development version (debug build)
164166
- `scopelint-beta` - Beta release version (release build with -beta suffix)
165167
- `scopelint` - Production version (from crates.io)

README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ A simple and opinionated tool designed for basic formatting/linting of Solidity
77
- [Usage](#usage)
88
- [`scopelint fmt`](#scopelint-fmt)
99
- [`scopelint check`](#scopelint-check)
10+
- [`scopelint fix`](#scopelint-fix)
1011
- [`scopelint spec`](#scopelint-spec)
1112
- [Development](#development)
1213

@@ -19,10 +20,11 @@ When using the [ScopeLift Foundry template](https://github.com/ScopeLift/foundry
1920

2021
## Usage
2122

22-
Once installed there are three commands:
23+
Once installed there are four commands:
2324

2425
- `scopelint fmt`
2526
- `scopelint check`
27+
- `scopelint fix`
2628
- `scopelint spec`
2729

2830
For all commands, please open issues for any bug reports, suggestions, or feature requests.
@@ -94,6 +96,14 @@ However, you can ignore specific rules for specific files using:
9496

9597
Supported rules: `error`, `import`, `variable`, `constant`, `test`, `script`, `src`, `eip712`
9698

99+
### `scopelint fix`
100+
101+
Applies safe, automatic fixes and then runs `scopelint check`. Currently supports:
102+
103+
- **Unused imports**: Removes unused symbols from named imports (`import { A, B } from "..."`) and removes entire aliased import lines (`import "..." as Alias`) when the alias is unused.
104+
105+
Only findings that are not ignored (via inline comments or `.scopelint`) are fixed. After fixing, any remaining convention or formatting issues are reported as with `scopelint check`.
106+
97107
### `scopelint spec`
98108

99109
Most developers don't have formal specifications they are building towards, and instead only have a general idea of what they want their contracts to do.

src/check/mod.rs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use colored::Colorize;
99
use itertools::Itertools;
1010
use solang_parser::pt::{Loc, SourceUnit};
1111
use std::{
12+
collections::HashSet,
1213
error::Error,
1314
ffi::OsStr,
1415
fs,
@@ -51,6 +52,90 @@ pub fn run(taplo_opts: taplo::formatter::Options) -> Result<(), Box<dyn Error>>
5152
}
5253
}
5354

55+
/// Applies safe fixes (e.g. remove unused imports), then runs check.
56+
///
57+
/// # Errors
58+
///
59+
/// Returns an error if fixes could not be applied or if convention checks still fail after
60+
/// fixing.
61+
pub fn run_fix(taplo_opts: taplo::formatter::Options) -> Result<(), Box<dyn Error>> {
62+
let path_config = CheckPaths::load();
63+
let results = validate(&path_config)?;
64+
65+
let fixable_imports: Vec<&utils::InvalidItem> = results
66+
.items()
67+
.iter()
68+
.filter(|item| {
69+
item.kind == utils::ValidatorKind::Import && !item.is_disabled && !item.is_ignored
70+
})
71+
.collect();
72+
73+
if fixable_imports.is_empty() {
74+
// No fixable import issues; run normal check and return its result.
75+
let valid_names = validate_conventions();
76+
let valid_fmt = validators::formatting::validate(taplo_opts);
77+
if valid_names.is_ok() && valid_fmt.is_ok() {
78+
return Ok(());
79+
}
80+
return Err("One or more checks failed, review above output".into());
81+
}
82+
83+
let file_config = file_config::FileConfig::load();
84+
85+
// Group fixable import items by file and collect symbol names to remove.
86+
let by_file: std::collections::HashMap<&str, HashSet<String>> = fixable_imports
87+
.iter()
88+
.map(|item| {
89+
let symbol = extract_unused_import_symbol(&item.text);
90+
(item.file.as_str(), symbol)
91+
})
92+
.fold(std::collections::HashMap::new(), |mut acc, (file, symbol)| {
93+
acc.entry(file).or_default().insert(symbol);
94+
acc
95+
});
96+
97+
let mut fixed_count = 0_usize;
98+
for (file_path, symbols) in &by_file {
99+
let path = Path::new(file_path);
100+
if !path.exists() {
101+
continue;
102+
}
103+
let mut parsed = parse(path)?;
104+
parsed.file_config = file_config.clone();
105+
parsed.path_config = path_config.clone();
106+
107+
if let Some(new_src) = validators::unused_imports::fix_source(&parsed, Some(symbols)) {
108+
fs::write(path, new_src)?;
109+
fixed_count += 1;
110+
}
111+
}
112+
113+
if fixed_count > 0 {
114+
eprintln!("{}: Fixed unused imports in {} file(s)", "info".bold().green(), fixed_count);
115+
}
116+
117+
// Re-run check and report any remaining issues.
118+
let valid_names = validate_conventions();
119+
let valid_fmt = validators::formatting::validate(taplo_opts);
120+
if valid_names.is_ok() && valid_fmt.is_ok() {
121+
Ok(())
122+
} else {
123+
Err("One or more checks failed, review above output".into())
124+
}
125+
}
126+
127+
/// Extracts the symbol name from an "Unused import: '`SymbolName`'" message.
128+
fn extract_unused_import_symbol(text: &str) -> String {
129+
const PREFIX: &str = "Unused import: '";
130+
const SUFFIX: char = '\'';
131+
if let Some(stripped) = text.strip_prefix(PREFIX) {
132+
if let Some(symbol) = stripped.strip_suffix(SUFFIX) {
133+
return symbol.to_string();
134+
}
135+
}
136+
text.to_string()
137+
}
138+
54139
// =============================
55140
// ======== Validations ========
56141
// =============================

src/check/report.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ impl Report {
3030
self.invalid_items.extend(items);
3131
}
3232

33+
/// Returns all invalid items (including ignored/disabled).
34+
#[must_use]
35+
pub fn items(&self) -> &[InvalidItem] {
36+
&self.invalid_items
37+
}
38+
3339
/// Returns true if no issues were found.
3440
#[must_use]
3541
pub fn is_valid(&self) -> bool {

src/check/validators/unused_imports.rs

Lines changed: 157 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,16 @@ use crate::check::{
33
Parsed,
44
};
55
use 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 "...";`
99
static 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;`
1317
static 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)]
145234
mod 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
}

src/config.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ pub enum Subcommands {
3939
/// Show changes without modifying files.
4040
check: bool,
4141
},
42+
#[clap(about = "Applies safe fixes (e.g. remove unused imports), then runs check.")]
43+
/// Applies safe fixes (e.g. remove unused imports), then runs check.
44+
Fix,
4245
#[clap(about = "Generates a specification for the current project from test names.")]
4346
/// Generates a specification for the current project from test names.
4447
Spec {

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ pub fn run(opts: &config::Opts) -> Result<(), Box<dyn Error>> {
4242
match &opts.subcommand {
4343
config::Subcommands::Check => check::run(taplo_opts),
4444
config::Subcommands::Fmt { check } => fmt::run(taplo_opts, *check),
45+
config::Subcommands::Fix => check::run_fix(taplo_opts),
4546
config::Subcommands::Spec { show_internal } => spec::run(*show_internal),
4647
}
4748
}

0 commit comments

Comments
 (0)