Skip to content

Commit bf7925d

Browse files
committed
feat: format command
Add a `format` command: - linters can be identified as formatters with the `is_formatter` config option - `lintrunner format` will run formatting lints and accept all changes - add infer_subcommands so you can do `lintrunner f`.
1 parent 0630560 commit bf7925d

File tree

5 files changed

+148
-48
lines changed

5 files changed

+148
-48
lines changed

src/lib.rs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ fn get_paths_from_file(file: AbsPath) -> Result<Vec<AbsPath>> {
121121
}
122122

123123
/// Represents the set of paths the user wants to lint.
124-
pub enum PathsToLint {
124+
pub enum PathsOpt {
125125
/// The user didn't specify any paths, so we'll automatically determine
126126
/// which paths to check.
127127
Auto,
@@ -150,7 +150,7 @@ pub enum RenderOpt {
150150

151151
pub fn do_lint(
152152
linters: Vec<Linter>,
153-
paths_to_lint: PathsToLint,
153+
paths_opt: PathsOpt,
154154
should_apply_patches: bool,
155155
render_opt: RenderOpt,
156156
enable_spinners: bool,
@@ -161,8 +161,8 @@ pub fn do_lint(
161161
linters.iter().map(|l| &l.code).collect::<Vec<_>>()
162162
);
163163

164-
let mut files = match paths_to_lint {
165-
PathsToLint::Auto => {
164+
let mut files = match paths_opt {
165+
PathsOpt::Auto => {
166166
let git_root = get_git_root()?;
167167
let relative_to = match revision_opt {
168168
RevisionOpt::Head => None,
@@ -173,9 +173,9 @@ pub fn do_lint(
173173
};
174174
get_changed_files(&git_root, relative_to.as_deref())?
175175
}
176-
PathsToLint::PathsCmd(paths_cmd) => get_paths_from_cmd(paths_cmd)?,
177-
PathsToLint::Paths(paths) => get_paths_from_input(paths)?,
178-
PathsToLint::PathsFile(file) => get_paths_from_file(file)?,
176+
PathsOpt::PathsCmd(paths_cmd) => get_paths_from_cmd(paths_cmd)?,
177+
PathsOpt::Paths(paths) => get_paths_from_input(paths)?,
178+
PathsOpt::PathsFile(file) => get_paths_from_file(file)?,
179179
};
180180

181181
// Sort and unique the files so we pass a consistent ordering to linters

src/lint_config.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ pub struct LintRunnerConfig {
1212
pub linters: Vec<LintConfig>,
1313
}
1414

15+
fn is_false(b: &bool) -> bool {
16+
return *b == false;
17+
}
18+
1519
/// Represents a single linter, along with all the information necessary to invoke it.
1620
///
1721
/// This goes in the linter configuration TOML file.
@@ -30,7 +34,7 @@ pub struct LintRunnerConfig {
3034
/// '@{{PATHSFILE}}'
3135
/// ]
3236
/// ```
33-
#[derive(Serialize, Deserialize)]
37+
#[derive(Serialize, Deserialize, Clone)]
3438
pub struct LintConfig {
3539
/// The name of the linter, conventionally capitals and numbers, no spaces,
3640
/// dashes, or underscores
@@ -103,6 +107,13 @@ pub struct LintConfig {
103107
/// ```toml
104108
/// command = ['python3', 'my_linter_init.py', '--dry-run={{DRYRUN}}']
105109
pub init_command: Option<Vec<String>>,
110+
111+
/// If true, this linter will be considered a formatter, and will invoked by
112+
/// `lintrunner format`. Formatters should be *safe*: people should be able
113+
/// to blindly accept the output without worrying that it will change the
114+
/// meaning of their code.
115+
#[serde(skip_serializing_if = "is_false", default = "bool::default")]
116+
pub is_formatter: bool,
106117
}
107118

108119
/// Given options specified by the user, return a list of linters to run.

src/main.rs

Lines changed: 75 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -10,80 +10,80 @@ use lintrunner::{
1010
path::AbsPath,
1111
persistent_data::PersistentDataStore,
1212
render::print_error,
13-
PathsToLint, RenderOpt, RevisionOpt,
13+
PathsOpt, RenderOpt, RevisionOpt,
1414
};
1515

1616
#[derive(Debug, Parser)]
17-
#[structopt(name = "lintrunner", about = "A lint runner")]
17+
#[clap(name = "lintrunner", about = "A lint runner", infer_subcommands(true))]
1818
struct Args {
1919
/// Verbose mode (-v, or -vv to show full list of paths being linted)
20-
#[clap(short, long, parse(from_occurrences))]
20+
#[clap(short, long, parse(from_occurrences), global = true)]
2121
verbose: u8,
2222

2323
/// Path to a toml file defining which linters to run
24-
#[clap(long, default_value = ".lintrunner.toml")]
24+
#[clap(long, default_value = ".lintrunner.toml", global = true)]
2525
config: String,
2626

2727
/// If set, any suggested patches will be applied
28-
#[clap(short, long)]
28+
#[clap(short, long, global = true)]
2929
apply_patches: bool,
3030

3131
/// Shell command that returns new-line separated paths to lint
3232
///
3333
/// Example: To run on all files in the repo, use `--paths-cmd='git grep -Il .'`.
34-
#[clap(long, conflicts_with = "paths-from")]
34+
#[clap(long, conflicts_with = "paths-from", global = true)]
3535
paths_cmd: Option<String>,
3636

3737
/// File with new-line separated paths to lint
38-
#[clap(long)]
38+
#[clap(long, global = true)]
3939
paths_from: Option<String>,
4040

4141
/// Lint all files that differ between the working directory and the
4242
/// specified revision. This argument can be any <tree-ish> that is accepted
4343
/// by `git diff-tree`
44-
#[clap(long, short, conflicts_with_all=&["paths", "paths-cmd", "paths-from"])]
44+
#[clap(long, short, conflicts_with_all=&["paths", "paths-cmd", "paths-from"], global = true)]
4545
revision: Option<String>,
4646

4747
/// Lint all files that differ between the merge base of HEAD with the
4848
/// specified revision and HEAD. This argument can be any <tree-sh> that is
4949
/// accepted by `git diff-tree`
5050
///
5151
/// Example: lintrunner -m master
52-
#[clap(long, short, conflicts_with_all=&["paths", "paths-cmd", "paths-from", "revision"])]
52+
#[clap(long, short, conflicts_with_all=&["paths", "paths-cmd", "paths-from", "revision"], global = true)]
5353
merge_base_with: Option<String>,
5454

5555
/// Comma-separated list of linters to skip (e.g. --skip CLANGFORMAT,NOQA)
56-
#[clap(long)]
56+
#[clap(long, global = true)]
5757
skip: Option<String>,
5858

5959
/// Comma-separated list of linters to run (opposite of --skip)
60-
#[clap(long)]
60+
#[clap(long, global = true)]
6161
take: Option<String>,
6262

6363
/// With 'default' show lint issues in human-readable format, for interactive use.
6464
/// With 'json', show lint issues as machine-readable JSON (one per line)
6565
/// With 'oneline', show lint issues in compact format (one per line)
66-
#[clap(long, arg_enum, default_value_t = RenderOpt::Default)]
66+
#[clap(long, arg_enum, default_value_t = RenderOpt::Default, global=true)]
6767
output: RenderOpt,
6868

69+
/// Paths to lint. lintrunner will still respect the inclusions and
6970
#[clap(subcommand)]
7071
cmd: Option<SubCommand>,
7172

72-
/// Paths to lint. lintrunner will still respect the inclusions and
7373
/// exclusions defined in .lintrunner.toml; manually specifying a path will
7474
/// not override them.
75-
#[clap(conflicts_with_all = &["paths-cmd", "paths-from"])]
75+
#[clap(conflicts_with_all = &["paths-cmd", "paths-from"], global = true)]
7676
paths: Vec<String>,
7777

7878
/// If set, always output with ANSI colors, even if we detect the output is
7979
/// not a user-attended terminal.
80-
#[clap(long)]
80+
#[clap(long, global = true)]
8181
force_color: bool,
8282

8383
/// If set, use ths provided path to store any metadata generated by
8484
/// lintrunner. By default, this is a platform-specific location for
8585
/// application data (e.g. $XDG_DATA_HOME for UNIX systems.)
86-
#[clap(long)]
86+
#[clap(long, global = true)]
8787
data_path: Option<String>,
8888
}
8989

@@ -95,6 +95,12 @@ enum SubCommand {
9595
#[clap(long, short)]
9696
dry_run: bool,
9797
},
98+
/// Run and accept changes for formatting linters only. Equivalent to
99+
/// `lintrunner --apply-patches --take <formatters>`.
100+
Format,
101+
102+
/// Run linters. This is the default if no subcommand is provided.
103+
Lint,
98104
}
99105

100106
fn do_main() -> Result<i32> {
@@ -120,6 +126,10 @@ fn do_main() -> Result<i32> {
120126

121127
let config_path = AbsPath::try_from(&args.config)
122128
.with_context(|| format!("Could not read lintrunner config at: '{}'", args.config))?;
129+
130+
let cmd = args.cmd.unwrap_or(SubCommand::Lint);
131+
let lint_runner_config = LintRunnerConfig::new(&config_path)?;
132+
123133
let skipped_linters = args.skip.map(|linters| {
124134
linters
125135
.split(',')
@@ -133,28 +143,32 @@ fn do_main() -> Result<i32> {
133143
.collect::<HashSet<_>>()
134144
});
135145

136-
let lint_runner_config = LintRunnerConfig::new(&config_path)?;
146+
// If we are formatting, the universe of linters to select from should be
147+
// restricted to only formatters.
148+
// (NOTE: we pay an allocation for `placeholder` even in cases where we are
149+
// just passing through a reference in the else-branch. This doesn't matter,
150+
// but if we want to fix it we should impl Cow for LintConfig and use that
151+
// instead.).
152+
let mut placeholder = Vec::new();
153+
let all_linters = if let SubCommand::Format = &cmd {
154+
let iter = lint_runner_config
155+
.linters
156+
.iter()
157+
.filter(|l| l.is_formatter)
158+
.map(|l| l.clone());
159+
placeholder.extend(iter);
160+
&placeholder
161+
} else {
162+
// If we're not formatting, all linters defined in the config are
163+
// eligible to run.
164+
&lint_runner_config.linters
165+
};
137166

138-
let linters = get_linters_from_config(
139-
&lint_runner_config.linters,
140-
skipped_linters,
141-
taken_linters,
142-
&config_path,
143-
)?;
167+
let linters =
168+
get_linters_from_config(all_linters, skipped_linters, taken_linters, &config_path)?;
144169

145170
let enable_spinners = args.verbose == 0 && args.output == RenderOpt::Default;
146-
147-
let paths_to_lint = if let Some(paths_file) = args.paths_from {
148-
let path_file = AbsPath::try_from(&paths_file)
149-
.with_context(|| format!("Failed to find `--paths-from` file '{}'", paths_file))?;
150-
PathsToLint::PathsFile(path_file)
151-
} else if let Some(paths_cmd) = args.paths_cmd {
152-
PathsToLint::PathsCmd(paths_cmd)
153-
} else if !args.paths.is_empty() {
154-
PathsToLint::Paths(args.paths)
155-
} else {
156-
PathsToLint::Auto
157-
};
171+
let persistent_data_store = PersistentDataStore::new(&config_path)?;
158172

159173
let revision_opt = if let Some(revision) = args.revision {
160174
RevisionOpt::Revision(revision)
@@ -164,19 +178,40 @@ fn do_main() -> Result<i32> {
164178
RevisionOpt::Head
165179
};
166180

167-
let persistent_data_store = PersistentDataStore::new(&config_path)?;
181+
let paths_opt = if let Some(paths_file) = args.paths_from {
182+
let path_file = AbsPath::try_from(&paths_file)
183+
.with_context(|| format!("Failed to find `--paths-from` file '{}'", paths_file))?;
184+
PathsOpt::PathsFile(path_file)
185+
} else if let Some(paths_cmd) = args.paths_cmd {
186+
PathsOpt::PathsCmd(paths_cmd)
187+
} else if !args.paths.is_empty() {
188+
PathsOpt::Paths(args.paths)
189+
} else {
190+
PathsOpt::Auto
191+
};
168192

169-
match args.cmd {
170-
Some(SubCommand::Init { dry_run }) => {
193+
match cmd {
194+
SubCommand::Init { dry_run } => {
171195
// Just run initialization commands, don't actually lint.
172196
do_init(linters, dry_run, &persistent_data_store, &config_path)
173197
}
174-
None => {
198+
SubCommand::Format => {
199+
check_init_changed(&persistent_data_store, &lint_runner_config)?;
200+
do_lint(
201+
linters,
202+
paths_opt,
203+
true, // always apply patches when we use the format command
204+
args.output,
205+
enable_spinners,
206+
revision_opt,
207+
)
208+
}
209+
SubCommand::Lint => {
175210
// Default command is to just lint.
176211
check_init_changed(&persistent_data_store, &lint_runner_config)?;
177212
do_lint(
178213
linters,
179-
paths_to_lint,
214+
paths_opt,
180215
args.apply_patches,
181216
args.output,
182217
enable_spinners,

tests/integration_test.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,3 +562,44 @@ fn excluding_dryrun_fails() -> Result<()> {
562562

563563
Ok(())
564564
}
565+
566+
#[test]
567+
fn format_command_doesnt_use_nonformat_linter() -> Result<()> {
568+
let data_path = tempfile::tempdir()?;
569+
let lint_message = LintMessage {
570+
path: Some("tests/fixtures/fake_source_file.rs".to_string()),
571+
line: Some(9),
572+
char: Some(1),
573+
code: "DUMMY".to_string(),
574+
name: "dummy failure".to_string(),
575+
severity: LintSeverity::Advice,
576+
original: None,
577+
replacement: None,
578+
description: Some("A dummy linter failure".to_string()),
579+
};
580+
let config = temp_config(&format!(
581+
"\
582+
[[linter]]
583+
code = 'TESTLINTER'
584+
include_patterns = ['**']
585+
command = ['echo', '{}']
586+
",
587+
serde_json::to_string(&lint_message)?
588+
))?;
589+
590+
let mut cmd = Command::cargo_bin("lintrunner")?;
591+
cmd.arg(format!("--config={}", config.path().to_str().unwrap()));
592+
cmd.arg(format!(
593+
"--data-path={}",
594+
data_path.path().to_str().unwrap()
595+
));
596+
597+
// Run on a file to ensure that the linter is run.
598+
cmd.arg("format");
599+
cmd.arg("README.md");
600+
// Should succeed because TESTLINTER was not run
601+
cmd.assert().success();
602+
assert_output_snapshot("format_command_doesnt_use_nonformat_linter", &mut cmd)?;
603+
604+
Ok(())
605+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
source: tests/integration_test.rs
3+
assertion_line: 20
4+
expression: output_lines
5+
---
6+
- "STDOUT:"
7+
- ok No lint issues.
8+
- Successfully applied all patches.
9+
- ""
10+
- ""
11+
- "STDERR:"
12+
- "WARNING: No previous init data found. If this is the first time you're running lintrunner, you should run `lintrunner init`."
13+

0 commit comments

Comments
 (0)