@@ -19,10 +19,12 @@ use std::sync::mpsc::channel;
19
19
20
20
use futures:: StreamExt ;
21
21
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 } ;
23
25
use jj_lib:: merged_tree:: MergedTreeBuilder ;
24
26
use jj_lib:: repo:: Repo ;
25
- use jj_lib:: repo_path:: RepoPathBuf ;
27
+ use jj_lib:: repo_path:: { RepoPathBuf , RepoPathUiConverter } ;
26
28
use jj_lib:: revset:: { RevsetExpression , RevsetIteratorExt } ;
27
29
use jj_lib:: store:: Store ;
28
30
use pollster:: FutureExt ;
@@ -31,8 +33,8 @@ use rayon::prelude::ParallelIterator;
31
33
use tracing:: instrument;
32
34
33
35
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 } ;
36
38
use crate :: ui:: Ui ;
37
39
38
40
/// Update files with formatting fixes or other changes
@@ -42,26 +44,60 @@ use crate::ui::Ui;
42
44
/// It can also be used to modify files with other tools like `sed` or `sort`.
43
45
///
44
46
/// 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.
50
53
///
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.
55
58
///
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):
60
94
///
61
95
/// [fix]
62
96
/// tool-command = ["rustfmt", "--emit", "stdout"]
63
97
///
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.
65
101
#[ derive( clap:: Args , Clone , Debug ) ]
66
102
#[ command( verbatim_doc_comment) ]
67
103
pub ( crate ) struct FixArgs {
@@ -82,6 +118,7 @@ pub(crate) fn cmd_fix(
82
118
args : & FixArgs ,
83
119
) -> Result < ( ) , CommandError > {
84
120
let mut workspace_command = command. workspace_helper ( ui) ?;
121
+ let tools_config = get_tools_config ( ui, command. settings ( ) . config ( ) ) ?;
85
122
let root_commits: Vec < CommitId > = if args. source . is_empty ( ) {
86
123
workspace_command. parse_revset ( & RevisionArg :: from (
87
124
command. settings ( ) . config ( ) . get_string ( "revsets.fix" ) ?,
@@ -166,15 +203,9 @@ pub(crate) fn cmd_fix(
166
203
}
167
204
168
205
// 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) ) ?;
175
206
let fixed_file_ids = fix_file_ids (
176
207
tx. repo ( ) . store ( ) . as_ref ( ) ,
177
- & tool_command ,
208
+ & tools_config ,
178
209
& unique_tool_inputs,
179
210
) ?;
180
211
@@ -261,20 +292,38 @@ struct ToolInput {
261
292
/// each failed input.
262
293
fn fix_file_ids < ' a > (
263
294
store : & Store ,
264
- tool_command : & CommandNameAndArgs ,
295
+ tools_config : & ToolsConfig ,
265
296
tool_inputs : & ' a HashSet < ToolInput > ,
266
- ) -> BackendResult < HashMap < & ' a ToolInput , FileId > > {
297
+ ) -> Result < HashMap < & ' a ToolInput , FileId > , CommandError > {
267
298
let ( updates_tx, updates_rx) = channel ( ) ;
268
299
// TODO: Switch to futures, or document the decision not to. We don't need
269
300
// threads unless the threads will be doing more than waiting for pipes.
270
301
tool_inputs. into_par_iter ( ) . try_for_each_init (
271
302
|| 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 {
278
327
let new_file_id =
279
328
store. write_file ( & tool_input. repo_path , & mut new_content. as_slice ( ) ) ?;
280
329
updates_tx. send ( ( tool_input, new_file_id) ) . unwrap ( ) ;
@@ -328,3 +377,104 @@ fn run_tool(
328
377
Err ( ( ) )
329
378
}
330
379
}
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
+ }
0 commit comments