// Copyright 2024 The Jujutsu Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use std::collections::HashMap; use std::io::Write as _; use std::path::Path; use std::process::Stdio; use clap_complete::ArgValueCompleter; use futures::AsyncReadExt as _; use futures::TryStreamExt as _; use itertools::Itertools as _; use jj_lib::backend::FileId; use jj_lib::commit::Commit; use jj_lib::fileset; use jj_lib::fileset::FilesetDiagnostics; use jj_lib::fileset::FilesetExpression; use jj_lib::fileset::FilesetParseContext; use jj_lib::fix::FileToFix; use jj_lib::fix::FixError; use jj_lib::fix::LineRange; use jj_lib::fix::ParallelFileFixer; use jj_lib::fix::RegionsToFormat; use jj_lib::fix::compute_changed_ranges; use jj_lib::fix::compute_file_line_count; use jj_lib::fix::fix_files; use jj_lib::matchers::Matcher; use jj_lib::repo::Repo as _; use jj_lib::repo_path::RepoPathUiConverter; use jj_lib::revset::RevsetStreamExt as _; use jj_lib::settings::UserSettings; use jj_lib::store::Store; use pollster::FutureExt as _; use tracing::instrument; use crate::cli_util::CommandHelper; use crate::cli_util::RevisionArg; use crate::cli_util::print_unmatched_explicit_paths; use crate::command_error::CommandError; use crate::command_error::config_error; use crate::command_error::print_parse_diagnostics; use crate::complete; use crate::config::CommandNameAndArgs; use crate::ui::Ui; /// Update files with formatting fixes or other changes /// /// The primary use case for this command is to apply the results of automatic /// code formatting tools to revisions that may not be properly formatted yet. /// It can also be used to modify files with other tools like `sed` or `sort`. /// /// The modification made by `jj fix` can be reviewed by `jj op show -p`. /// /// ### How it works /// /// The changed files in the given revisions will be updated with any fixes /// determined by passing their file content through any external tools the user /// has configured for those files. Descendants will also be updated by passing /// their versions of the same files through the same tools, which will ensure /// that the fixes are not lost. This will never result in new conflicts. Files /// with existing conflicts will be updated on all sides of the conflict, which /// can potentially increase or decrease the number of conflict markers. /// /// ### Deduplication /// /// When fixing multiple commits, if the same file content appears at the same /// path in different commits, the tool is run only once and the result is /// reused. This means that tools used with `jj fix` must produce deterministic /// output. /// /// ### Configuration /// /// See `jj help -k config` chapter `Code formatting and other file content /// transformations` to understand how to configure your tools. /// /// ### Execution Example /// /// Let's consider the following configuration is set. We have two code /// formatters (`clang-format` and `black`), which apply to three different /// file extensions (`.cc`, `.h`, and `.py`): /// /// ```toml /// [fix.tools.clang-format] /// command = ["/usr/bin/clang-format", "--assume-filename=$path"] /// patterns = ["glob:'**/*.cc'", /// "glob:'**/*.h'"] /// /// [fix.tools.black] /// command = ["/usr/bin/black", "-", "--stdin-filename=$path"] /// patterns = ["glob:'**/*.py'"] /// ``` /// /// Now, let's see what would happen to the following history, when executing /// `jj fix`. /// /// ```text /// C (mutable) /// | Modifies file: foo.py /// | /// B @ (working copy - mutable) /// | Modifies file: README.md /// | /// A (mutable) /// | Modifies files: src/bar.cc and src/bar.h /// | /// X (immutable) /// ``` /// /// By default, `jj fix` will modify revisions that matches the revset /// `reachable(@, mutable())` (see `jj help -k revsets`) which corresponds to /// the revisions `A`, `B` and `C` here. /// /// The following operations will then happen: /// /// - For revision `A`, content from this revision for files `src/bar.cc` and /// `src/bar.h` will each be provided to `clang-format` and the result output /// will be used to recreate revision `A` which we will call `A'`. All other /// files are untouched. /// - For revision `B`, same thing happen for files `src/bar.cc` and `src/bar.h` /// Their content from revision `B` will go through `clang-format`. The file /// `README.md` as any other files, are untouched as no pattern matches it. We /// obtain revision `B'`. /// - For revision `C`, `src/bar.cc` and `src/bar.h` goes through `clang-format` /// and file `foo.py` is fixed using `black`. Any other file is untouched. We /// obtain revision `C'`. /// /// ```text /// C (mutable) /-> C' /// | src/bar.cc -> clang-format -| | /// | src/bar.h --> clang-format -| | /// | foo.py -----> black --------| | /// | * --------------------------/ | /// | | /// B @ (working copy - mutable) /-> B' @ /// | src/bar.cc -> clang-format -| | /// | src/bar.h --> clang-format -| | /// | * --------------------------| | /// | | /// A (mutable) /-> A' /// | src/bar.cc -> clang-format -| | /// | src/bar.h --> clang-format -| | /// | * --------------------------/ | /// | | /// X (immutable) X /// ``` /// /// The revisions are now all correctly formatted according to the /// configuration. #[derive(clap::Args, Clone, Debug)] #[command(verbatim_doc_comment)] pub(crate) struct FixArgs { /// Fix files in the specified revision(s) and their descendants. If no /// revisions are specified, this defaults to the `revsets.fix` setting, or /// `reachable(@, mutable())` if it is not set. #[arg(long, short, value_name = "REVSETS")] #[arg(add = ArgValueCompleter::new(complete::revset_expression_mutable))] source: Vec, /// Fix only these paths #[arg(value_name = "FILESETS", value_hint = clap::ValueHint::AnyPath)] paths: Vec, /// Fix unchanged files in addition to changed ones. If no paths are /// specified, all files in the repo will be fixed. #[arg(long)] include_unchanged_files: bool, /// Format all lines instead of only modified lines. /// /// If the formatter doesn't support formatting only modified lines, then /// this option has no effect since the formatter always formats all lines. #[arg(long, short)] all_lines: bool, } #[instrument(skip_all)] pub(crate) async fn cmd_fix( ui: &mut Ui, command: &CommandHelper, args: &FixArgs, ) -> Result<(), CommandError> { let mut workspace_command = command.workspace_helper(ui).await?; let workspace_root = workspace_command.workspace_root().to_owned(); let path_converter = workspace_command.path_converter().to_owned(); let tools_config = get_tools_config( ui, workspace_command.settings(), &workspace_command.env().fileset_parse_context_for_config(), )?; let target_expr = if args.source.is_empty() { let revs = workspace_command.settings().get_string("revsets.fix")?; workspace_command.parse_revset(ui, &RevisionArg::from(revs))? } else { workspace_command.parse_union_revsets(ui, &args.source)? } .resolve()?; workspace_command .check_rewritable_expr(&target_expr) .await?; let repo = workspace_command.repo(); let commits: Vec = target_expr .descendants() .evaluate(repo.as_ref())? .stream() .commits(repo.store()) .try_collect() .await?; let commit_ids = commits .iter() .map(|commit| commit.id().clone()) .collect_vec(); let trees: Vec<_> = commits.iter().map(|commit| commit.tree()).collect(); let fileset_expression = workspace_command.parse_file_patterns(ui, &args.paths)?; let matcher = fileset_expression.to_matcher(); let mut tx = workspace_command.start_transaction(); let mut parallel_fixer = ParallelFileFixer::new(|store, file_to_fix| { fix_one_file( ui, &workspace_root, &path_converter, &tools_config, store, file_to_fix, args.all_lines, ) .block_on() }); print_unmatched_explicit_paths(ui, tx.base_workspace_helper(), &fileset_expression, &trees)?; let summary = fix_files( commit_ids, &matcher, args.include_unchanged_files, tx.repo_mut(), &mut parallel_fixer, ) .await?; writeln!( ui.status(), "Fixed {} commits of {} checked.", summary.num_fixed_commits, summary.num_checked_commits )?; tx.finish(ui, format!("fixed {} commits", summary.num_fixed_commits)) .await } /// Invokes all matching tools (if any) to file_to_fix. If the content is /// successfully transformed the new content is written and the new FileId is /// returned. Returns None if the content is unchanged. /// /// The matching tools are invoked in order, with the result of one tool feeding /// into the next tool. Returns FixError if there is an error reading or writing /// the file. However, if a tool invocation fails for whatever reason, the tool /// is simply skipped and we proceed to invoke the next tool (this is /// indistinguishable from succeeding with no changes). /// /// TODO: Better error handling so we can tell the user what went wrong with /// each failed input. async fn fix_one_file( ui: &Ui, workspace_root: &Path, path_converter: &RepoPathUiConverter, tools_config: &ToolsConfig, store: &Store, file_to_fix: &FileToFix, all_lines_arg: bool, ) -> Result, FixError> { let mut matching_tools = tools_config .tools .iter() .filter(|tool_config| tool_config.matcher.matches(&file_to_fix.repo_path)) .peekable(); if matching_tools.peek().is_none() { return Ok(None); } // The first matching tool gets its input from the committed file, and any // subsequent matching tool gets its input from the previous matching tool's // output. // TODO: Consider adding a check for some max file size config or some global // limit for both `old_content` and `base_content`. let mut old_content = vec![]; let mut read = store .read_file(&file_to_fix.repo_path, &file_to_fix.file_id) .await?; read.read_to_end(&mut old_content).await?; // Do not run any tools on empty files. if old_content.is_empty() { return Ok(None); } // Load the base content from the file_to_fix (if exists) iff any tool needs it. let base_content = if all_lines_arg { None } else { match &file_to_fix.base_file_id { Some(base_file_id) if matching_tools.clone().any(|t| t.line_range_arg.is_some()) => { let mut content = vec![]; let mut read = store .read_file(&file_to_fix.repo_path, base_file_id) .await?; read.read_to_end(&mut content).await?; Some(content) } _ => None, } }; let new_content = matching_tools.fold(old_content.clone(), |prev_content, tool_config| { let mut extra_args = Vec::new(); if let Some(line_range_arg) = &tool_config.line_range_arg { let RegionsToFormat::LineRanges(ranges) = compute_regions_to_format(base_content.as_deref(), &prev_content); if ranges.is_empty() && !tool_config.run_tool_if_zero_line_ranges { // Don't run the tool if there are no line ranges to format and the tool is // configured to not run in that case. return prev_content; } for range in ranges { extra_args.push( line_range_arg .replace("$first", &range.first.to_string()) .replace("$last", &range.last.to_string()), ); } } match run_tool( ui, workspace_root, path_converter, &tool_config.command, file_to_fix, &prev_content, &extra_args, ) { Ok(next_content) => next_content, // TODO: Because the stderr is passed through, this isn't always failing // silently, but it should do something better will the exit code, tool // name, etc. Err(()) => prev_content, } }); if new_content != old_content { // TODO: send futures back over channel let new_file_id = store .write_file(&file_to_fix.repo_path, &mut new_content.as_slice()) .await?; return Ok(Some(new_file_id)); } Ok(None) } /// Computes the modified line ranges between the base and current file. pub fn compute_regions_to_format( base_content: Option<&[u8]>, current_content: &[u8], ) -> RegionsToFormat { if current_content.is_empty() { // If the current file content is empty, then there are no regions to format. RegionsToFormat::LineRanges(vec![]) } else if let Some(base) = base_content { // If the base file content is available, then compute the modified line ranges // to format between the base and current file. compute_changed_ranges(base, current_content) } else { // Otherwise, format the entire file. let line_count = compute_file_line_count(current_content); RegionsToFormat::LineRanges(vec![LineRange { first: 1, last: line_count, }]) } } /// Runs the `tool_command` to fix the given file content. /// /// The `old_content` is assumed to be that of the `file_to_fix`'s `FileId`, but /// this is not verified. /// /// Returns the new file content, whose value will be the same as `old_content` /// unless the command introduced changes. Returns `None` if there were any /// failures when starting, stopping, or communicating with the subprocess. fn run_tool( ui: &Ui, workspace_root: &Path, path_converter: &RepoPathUiConverter, tool_command: &CommandNameAndArgs, file_to_fix: &FileToFix, old_content: &[u8], extra_args: &[String], ) -> Result, ()> { let mut vars: HashMap<&str, &str> = HashMap::new(); vars.insert("path", file_to_fix.repo_path.as_internal_file_string()); // TODO: workspace_root.to_str() returns None if the workspace path is not // UTF-8, but we ignore that failure so `jj fix` still runs in that // situation. Maybe we should do something like substituting bytes instead // of strings so we can handle any Path here. if let Some(root) = workspace_root.to_str() { vars.insert("root", root); } let mut command = tool_command.to_command_with_variables(&vars); if !extra_args.is_empty() { command.args(extra_args); } tracing::debug!(?command, ?file_to_fix.repo_path, "spawning fix tool"); let Ok(mut child) = command .current_dir(workspace_root) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() else { writeln!( ui.warning_default(), "Failed to start `{}`.", tool_command.split_name(), ) .ok(); return Err(()); }; let mut stdin = child.stdin.take().expect( "The child process is created with piped stdin, and it's our first access to stdin.", ); let output = std::thread::scope(|s| { s.spawn(move || { stdin.write_all(old_content).ok(); }); child.wait_with_output().or(Err(())) })?; tracing::debug!(?command, ?output.status, "fix tool exited:"); if !output.stderr.is_empty() { let mut stderr = ui.stderr(); writeln!( stderr, "{}:", path_converter.format_file_path(&file_to_fix.repo_path) ) .ok(); stderr.write_all(&output.stderr).ok(); writeln!(stderr).ok(); } if output.status.success() { Ok(output.stdout) } else { writeln!( ui.warning_default(), "Fix tool `{}` exited with non-zero exit code for `{}`", tool_command.split_name(), path_converter.format_file_path(&file_to_fix.repo_path) ) .ok(); Err(()) } } /// Represents an entry in the `fix.tools` config table. struct ToolConfig { /// The command that will be run to fix a matching file. command: CommandNameAndArgs, /// The matcher that determines if this tool matches a file. matcher: Box, /// Whether the tool is enabled enabled: bool, /// Arguments to pass changed line ranges to the tool. /// The string is a template where `$first` and `$last` are replaced by the /// 1-based first and last inclusive line numbers of the changed range. /// For example, `"--lines=$first-$last"`. line_range_arg: Option, /// Whether to run the tool invocation for this tool on files that had /// zero line ranges to format in the revision being fixed. /// /// Defaults to `false`, meaning the tool is skipped for these files. /// This default behavior serves two main purposes: /// - Avoiding unnecessary executions of tools when no line ranges are /// modified. /// - Preventing tools configured with `line-range-arg` from making overly /// broad changes (formatting the whole file) when no line ranges are /// available. /// /// Setting this to `true` is useful if the tool should run regardless of /// diffs (e.g., to sort imports, or run with /// `--include-unchanged-files`). run_tool_if_zero_line_ranges: bool, // TODO: Store the `name` field here and print it with the command's stderr, to clearly // associate any errors/warnings with the tool and its configuration entry. } /// Represents the `fix.tools` config table. struct ToolsConfig { /// Some tools, stored in the order they will be executed if more than one /// of them matches the same file. tools: Vec, } /// Simplifies deserialization of the config values while building a ToolConfig. #[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize)] #[serde(rename_all = "kebab-case")] struct RawToolConfig { command: CommandNameAndArgs, patterns: Vec, #[serde(default = "default_tool_enabled")] enabled: bool, #[serde(default)] line_range_arg: Option, #[serde(default)] run_tool_if_zero_line_ranges: bool, } fn default_tool_enabled() -> bool { true } /// Parses the `fix.tools` config table. /// /// Fails if any of the commands or patterns are obviously unusable, but does /// not check for issues that might still occur later like missing executables. /// This is a place where we could fail earlier in some cases, though. fn get_tools_config( ui: &mut Ui, settings: &UserSettings, fileset_context: &FilesetParseContext, ) -> Result { let mut tools: Vec = settings .table_keys("fix.tools") // Sort keys early so errors are deterministic. .sorted() .map(|name| -> Result { let mut diagnostics = FilesetDiagnostics::new(); let tool: RawToolConfig = settings.get(["fix", "tools", name])?; let expression = FilesetExpression::union_all( tool.patterns .iter() .map(|arg| fileset::parse(&mut diagnostics, arg, fileset_context)) .try_collect()?, ); if tool.line_range_arg.is_none() && tool.run_tool_if_zero_line_ranges { return Err(config_error( "run-tool-if-zero-line-ranges can only be set when line-range-arg is set", )); } print_parse_diagnostics(ui, &format!("In `fix.tools.{name}`"), &diagnostics)?; Ok(ToolConfig { command: tool.command, matcher: expression.to_matcher(), enabled: tool.enabled, line_range_arg: tool.line_range_arg, run_tool_if_zero_line_ranges: tool.run_tool_if_zero_line_ranges, }) }) .try_collect()?; if tools.is_empty() { return Err(config_error("No `fix.tools` are configured")); } tools.retain(|t| t.enabled); if tools.is_empty() { Err(config_error( "At least one entry of `fix.tools` must be enabled.".to_string(), )) } else { Ok(ToolsConfig { tools }) } } #[cfg(test)] mod tests { use super::*; fn line_range(first: usize, last: usize) -> LineRange { LineRange::new(first, last) } #[test] fn test_compute_regions_to_format_default() { // Base content None. assert_eq!( compute_regions_to_format(None, b"a\nb\nc\n"), RegionsToFormat::LineRanges(vec![line_range(1, 3)]) ); // Empty base content. assert_eq!( compute_regions_to_format(Some(b""), b"a\nb\nc\n"), RegionsToFormat::LineRanges(vec![line_range(1, 3)]) ); // Modified base content. assert_eq!( compute_regions_to_format(Some(b"a\nB\nc\n"), b"a\nb\nc\n"), RegionsToFormat::LineRanges(vec![line_range(2, 2)]) ); // Deleted base content. assert_eq!( compute_regions_to_format(Some(b"a\nb\nc\nd\n"), b"a\nb\nc\n"), RegionsToFormat::LineRanges(vec![]) ); // Multiple line ranges. assert_eq!( compute_regions_to_format(Some(b"A\nb\nC\n"), b"a\nb\nc\n"), RegionsToFormat::LineRanges(vec![line_range(1, 1), line_range(3, 3)]) ); // Deleted current content. assert_eq!( compute_regions_to_format(Some(b"a\nb\nc\n"), b""), RegionsToFormat::LineRanges(vec![]) ); } }