Skip to content

Commit 89f5d16

Browse files
hoopersteadmon
andcommitted
cli jj fix: add ability to configure multiple tools for different filesets
The high level changes include: - Reworking `fix_file_ids()` to loop over multiple candidate tools per file, piping file content between them. Only the final file content is written to the store, and content is no longer read for changed files that don't match any of the configured patterns. - New struct `ToolsConfig` to represent the parsed/validated configuration. - New function `get_tools_config()` to create a `ToolsConfig` from a `Config`. - New tests; the only old behavior that has changed is that we don't require `fix.tool-command` if `fix.tools` defines one or more tools. The general approach to validating the config is to fail early if anything is weird. Co-Authored-By: Josh Steadmon <[email protected]>
1 parent 8fe2274 commit 89f5d16

File tree

7 files changed

+888
-107
lines changed

7 files changed

+888
-107
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
3232

3333
### Deprecations
3434

35+
* The original configuration syntax for `jj fix` is now deprecated in favor of
36+
one that allows defining multiple tools that can affect different filesets.
37+
These can be used in combination for now. See `jj help fix` for details.
38+
3539
### New features
3640

3741
* External diff tools can now be configured to invoke the tool on each file
@@ -85,6 +89,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8589
* [The default commit description template](docs/config.md#default-description)
8690
can now be configured by `templates.draft_commit_description`.
8791

92+
* `jj fix` can now be configured to run different tools on different filesets.
93+
This simplifies the use case of configuring code formatters for specific file
94+
types. See `jj help fix` for details.
95+
8896
### Fixed bugs
8997

9098
* `jj diff --git` no longer shows the contents of binary files.

cli/src/commands/fix.rs

Lines changed: 183 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,12 @@ use std::sync::mpsc::channel;
1919

2020
use futures::StreamExt;
2121
use itertools::Itertools;
22-
use jj_lib::backend::{BackendError, BackendResult, CommitId, FileId, TreeValue};
22+
use jj_lib::backend::{BackendError, CommitId, FileId, TreeValue};
23+
use jj_lib::fileset::{self, FilesetExpression};
24+
use jj_lib::matchers::{EverythingMatcher, Matcher};
2325
use jj_lib::merged_tree::MergedTreeBuilder;
2426
use jj_lib::repo::Repo;
25-
use jj_lib::repo_path::RepoPathBuf;
27+
use jj_lib::repo_path::{RepoPathBuf, RepoPathUiConverter};
2628
use jj_lib::revset::{RevsetExpression, RevsetIteratorExt};
2729
use jj_lib::store::Store;
2830
use pollster::FutureExt;
@@ -31,8 +33,8 @@ use rayon::prelude::ParallelIterator;
3133
use tracing::instrument;
3234

3335
use crate::cli_util::{CommandHelper, RevisionArg};
34-
use crate::command_error::{config_error_with_message, CommandError};
35-
use crate::config::CommandNameAndArgs;
36+
use crate::command_error::{config_error, CommandError};
37+
use crate::config::{to_toml_value, CommandNameAndArgs};
3638
use crate::ui::Ui;
3739

3840
/// Update files with formatting fixes or other changes
@@ -42,26 +44,60 @@ use crate::ui::Ui;
4244
/// It can also be used to modify files with other tools like `sed` or `sort`.
4345
///
4446
/// The changed files in the given revisions will be updated with any fixes
45-
/// determined by passing their file content through the external tool.
46-
/// Descendants will also be updated by passing their versions of the same files
47-
/// through the same external tool, which will never result in new conflicts.
48-
/// Files with existing conflicts will be updated on all sides of the conflict,
49-
/// which can potentially increase or decrease the number of conflict markers.
47+
/// determined by passing their file content through any external tools the user
48+
/// has configured for those files. Descendants will also be updated by passing
49+
/// their versions of the same files through the same tools, which will ensure
50+
/// that the fixes are not lost. This will never result in new conflicts. Files
51+
/// with existing conflicts will be updated on all sides of the conflict, which
52+
/// can potentially increase or decrease the number of conflict markers.
5053
///
51-
/// The external tool must accept the current file content on standard input,
52-
/// and return the updated file content on standard output. The output will not
53-
/// be used unless the tool exits with a successful exit code. Output on
54-
/// standard error will be passed through to the terminal.
54+
/// The external tools must accept the current file content on standard input,
55+
/// and return the updated file content on standard output. A tool's output will
56+
/// not be used unless it exits with a successful exit code. Output on standard
57+
/// error will be passed through to the terminal.
5558
///
56-
/// The configuration schema is expected to change in the future. For now, it
57-
/// defines a single command that will affect all changed files in the specified
58-
/// revisions. For example, to format some Rust code changed in the working copy
59-
/// revision, you could write this configuration:
59+
/// Tools are defined in a table where the keys are arbitrary identifiers and
60+
/// the values have the following properties:
61+
/// - `command`: The arguments used to run the tool. The first argument is the
62+
/// path to an executable file. Arguments can contain the substring `$path`,
63+
/// which will be replaced with the repo-relative path of the file being
64+
/// fixed. It is useful to provide the path to tools that include the path in
65+
/// error messages, or behave differently based on the directory or file
66+
/// name.
67+
/// - `patterns`: Determines which files the tool will affect. If this list is
68+
/// empty, no files will be affected by the tool. If there are multiple
69+
/// patterns, the tool is applied only once to each file in the union of the
70+
/// patterns.
71+
///
72+
/// For example, the following configuration defines how two code formatters
73+
/// (`clang-format` and `black`) will apply to three different file extensions
74+
/// (.cc, .h, and .py):
75+
///
76+
/// [fix.tools.clang-format]
77+
/// command = ["/usr/bin/clang-format", "--assume-filename=$path"]
78+
/// patterns = ["glob:'**/*.cc'",
79+
/// "glob:'**/*.h'"]
80+
///
81+
/// [fix.tools.black]
82+
/// command = ["/usr/bin/black", "-", "--stdin-filename=$path"]
83+
/// patterns = ["glob:'**/*.py'"]
84+
///
85+
/// Execution order of tools that affect the same file is deterministic, but
86+
/// currently unspecified, and may change between releases. If two tools affect
87+
/// the same file, the second tool to run will receive its input from the
88+
/// output of the first tool.
89+
///
90+
/// There is also a deprecated configuration schema that defines a single
91+
/// command that will affect all changed files in the specified revisions. For
92+
/// example, the following configuration would apply the Rust formatter to all
93+
/// changed files (whether they are Rust files or not):
6094
///
6195
/// [fix]
6296
/// tool-command = ["rustfmt", "--emit", "stdout"]
6397
///
64-
/// And then run the command `jj fix -s @`.
98+
/// The tool defined by `tool-command` acts as if it was the first entry in
99+
/// `fix.tools`, and uses `pattern = "all()"``. Support for `tool-command`
100+
/// will be removed in a future version.
65101
#[derive(clap::Args, Clone, Debug)]
66102
#[command(verbatim_doc_comment)]
67103
pub(crate) struct FixArgs {
@@ -82,6 +118,7 @@ pub(crate) fn cmd_fix(
82118
args: &FixArgs,
83119
) -> Result<(), CommandError> {
84120
let mut workspace_command = command.workspace_helper(ui)?;
121+
let tools_config = get_tools_config(ui, command.settings().config())?;
85122
let root_commits: Vec<CommitId> = if args.source.is_empty() {
86123
workspace_command.parse_revset(&RevisionArg::from(
87124
command.settings().config().get_string("revsets.fix")?,
@@ -166,15 +203,9 @@ pub(crate) fn cmd_fix(
166203
}
167204

168205
// Run the configured tool on all of the chosen inputs.
169-
// TODO: Support configuration of multiple tools and which files they affect.
170-
let tool_command: CommandNameAndArgs = command
171-
.settings()
172-
.config()
173-
.get("fix.tool-command")
174-
.map_err(|err| config_error_with_message("Invalid `fix.tool-command`", err))?;
175206
let fixed_file_ids = fix_file_ids(
176207
tx.repo().store().as_ref(),
177-
&tool_command,
208+
&tools_config,
178209
&unique_tool_inputs,
179210
)?;
180211

@@ -261,20 +292,38 @@ struct ToolInput {
261292
/// each failed input.
262293
fn fix_file_ids<'a>(
263294
store: &Store,
264-
tool_command: &CommandNameAndArgs,
295+
tools_config: &ToolsConfig,
265296
tool_inputs: &'a HashSet<ToolInput>,
266-
) -> BackendResult<HashMap<&'a ToolInput, FileId>> {
297+
) -> Result<HashMap<&'a ToolInput, FileId>, CommandError> {
267298
let (updates_tx, updates_rx) = channel();
268299
// TODO: Switch to futures, or document the decision not to. We don't need
269300
// threads unless the threads will be doing more than waiting for pipes.
270301
tool_inputs.into_par_iter().try_for_each_init(
271302
|| updates_tx.clone(),
272-
|updates_tx, tool_input| -> Result<(), BackendError> {
273-
let mut read = store.read_file(&tool_input.repo_path, &tool_input.file_id)?;
274-
let mut old_content = vec![];
275-
read.read_to_end(&mut old_content).unwrap();
276-
if let Ok(new_content) = run_tool(tool_command, tool_input, &old_content) {
277-
if new_content != *old_content {
303+
|updates_tx, tool_input| -> Result<(), CommandError> {
304+
let mut matching_tools = tools_config
305+
.tools
306+
.iter()
307+
.filter(|tool_config| tool_config.matcher.matches(&tool_input.repo_path))
308+
.peekable();
309+
if matching_tools.peek().is_some() {
310+
// The first matching tool gets its input from the committed file, and any
311+
// subsequent matching tool gets its input from the previous matching tool's
312+
// output.
313+
let mut old_content = vec![];
314+
let mut read = store.read_file(&tool_input.repo_path, &tool_input.file_id)?;
315+
read.read_to_end(&mut old_content)?;
316+
let new_content =
317+
matching_tools.fold(old_content.clone(), |prev_content, tool_config| {
318+
match run_tool(&tool_config.command, tool_input, &prev_content) {
319+
Ok(next_content) => next_content,
320+
// TODO: Because the stderr is passed through, this isn't always failing
321+
// silently, but it should do something better will the exit code, tool
322+
// name, etc.
323+
Err(_) => prev_content,
324+
}
325+
});
326+
if new_content != old_content {
278327
let new_file_id =
279328
store.write_file(&tool_input.repo_path, &mut new_content.as_slice())?;
280329
updates_tx.send((tool_input, new_file_id)).unwrap();
@@ -328,3 +377,104 @@ fn run_tool(
328377
Err(())
329378
}
330379
}
380+
381+
/// Represents an entry in the `fix.tools` config table.
382+
struct ToolConfig {
383+
/// The command that will be run to fix a matching file.
384+
command: CommandNameAndArgs,
385+
/// The matcher that determines if this tool matches a file.
386+
matcher: Box<dyn Matcher>,
387+
// TODO: Store the `name` field here and print it with the command's stderr, to clearly
388+
// associate any errors/warnings with the tool and its configuration entry.
389+
}
390+
391+
/// Represents the `fix.tools` config table.
392+
struct ToolsConfig {
393+
/// Some tools, stored in the order they will be executed if more than one
394+
/// of them matches the same file.
395+
tools: Vec<ToolConfig>,
396+
}
397+
398+
/// Simplifies deserialization of the config values while building a ToolConfig.
399+
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize)]
400+
#[serde(rename_all = "kebab-case")]
401+
struct RawToolConfig {
402+
command: CommandNameAndArgs,
403+
patterns: Vec<String>,
404+
}
405+
406+
/// Parses the `fix.tools` config table.
407+
///
408+
/// Parses the deprecated `fix.tool-command` config as if it was the first entry
409+
/// in `fix.tools`.
410+
///
411+
/// Fails if any of the commands or patterns are obviously unusable, but does
412+
/// not check for issues that might still occur later like missing executables.
413+
/// This is a place where we could fail earlier in some cases, though.
414+
fn get_tools_config(ui: &mut Ui, config: &config::Config) -> Result<ToolsConfig, CommandError> {
415+
let mut tools_config = ToolsConfig { tools: Vec::new() };
416+
// TODO: Remove this block of code and associated documentation after at least
417+
// one release where the feature is marked deprecated.
418+
if let Ok(tool_command) = config.get::<CommandNameAndArgs>("fix.tool-command") {
419+
// This doesn't change the displayed indices of the `fix.tools` definitions, and
420+
// doesn't have a `name` that could conflict with them. That would matter more
421+
// if we already had better error handling that made use of the `name`.
422+
tools_config.tools.push(ToolConfig {
423+
command: tool_command,
424+
matcher: Box::new(EverythingMatcher),
425+
});
426+
427+
writeln!(
428+
ui.warning_default(),
429+
r"The `fix.tool-command` config option is deprecated and will be removed in a future version."
430+
)?;
431+
writeln!(
432+
ui.hint_default(),
433+
r###"Replace it with the following:
434+
[fix.tools.legacy-tool-command]
435+
command = {}
436+
patterns = ["all()"]
437+
"###,
438+
to_toml_value(&config.get::<config::Value>("fix.tool-command").unwrap()).unwrap()
439+
)?;
440+
}
441+
if let Ok(tools_table) = config.get_table("fix.tools") {
442+
// Convert the map into a sorted vector early so errors are deterministic.
443+
let mut tools: Vec<ToolConfig> = tools_table
444+
.into_iter()
445+
.sorted_by(|a, b| a.0.cmp(&b.0))
446+
.map(|(_name, value)| -> Result<ToolConfig, CommandError> {
447+
let tool: RawToolConfig = value.try_deserialize()?;
448+
Ok(ToolConfig {
449+
command: tool.command,
450+
matcher: FilesetExpression::union_all(
451+
tool.patterns
452+
.iter()
453+
.map(|arg| {
454+
fileset::parse(
455+
arg,
456+
&RepoPathUiConverter::Fs {
457+
cwd: "".into(),
458+
base: "".into(),
459+
},
460+
)
461+
})
462+
.try_collect()?,
463+
)
464+
.to_matcher(),
465+
})
466+
})
467+
.try_collect()?;
468+
tools_config.tools.append(&mut tools);
469+
}
470+
if tools_config.tools.is_empty() {
471+
// TODO: This is not a useful message when one or both fields are present but
472+
// have the wrong type. After removing `fix.tool-command`, it will be simpler to
473+
// propagate any errors from `config.get_array("fix.tools")`.
474+
Err(config_error(
475+
"At least one entry of `fix.tools` or `fix.tool-command` is required.".to_string(),
476+
))
477+
} else {
478+
Ok(tools_config)
479+
}
480+
}

cli/src/config-schema.json

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -506,7 +506,7 @@
506506
}
507507
}
508508
},
509-
"fix": {
509+
"fix": {
510510
"type": "object",
511511
"description": "Settings for jj fix",
512512
"properties": {
@@ -515,7 +515,31 @@
515515
"items": {
516516
"type": "string"
517517
},
518-
"description": "Shell command that takes file content on stdin and returns fixed file content on stdout"
518+
"description": "Shell command that takes file content on stdin and returns fixed file content on stdout (deprecated)"
519+
},
520+
"tools": {
521+
"type": "object",
522+
"additionalProperties": {
523+
"type": "object",
524+
"description": "Settings for how specific filesets are affected by a tool",
525+
"properties": {
526+
"command": {
527+
"type": "array",
528+
"items": {
529+
"type": "string"
530+
},
531+
"description": "Arguments used to execute this tool"
532+
},
533+
"patterns": {
534+
"type": "array",
535+
"items": {
536+
"type": "string"
537+
},
538+
"description": "Filesets that will be affected by this tool"
539+
}
540+
}
541+
},
542+
"description": "Settings for tools run by jj fix"
519543
}
520544
}
521545
}

0 commit comments

Comments
 (0)