use std::collections::HashMap;
use std::io::Write as _;
use std::path::Path;
use std::process::Stdio;
use clap_complete::ArgValueCompleter;
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 tokio::io::AsyncReadExt 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;
#[derive(clap::Args, Clone, Debug)]
#[command(verbatim_doc_comment)]
pub(crate) struct FixArgs {
#[arg(long, short, value_name = "REVSETS")]
#[arg(add = ArgValueCompleter::new(complete::revset_expression_mutable))]
source: Vec<RevisionArg>,
#[arg(value_name = "FILESETS", value_hint = clap::ValueHint::AnyPath)]
paths: Vec<String>,
#[arg(long)]
include_unchanged_files: bool,
#[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)?;
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<Commit> = 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
}
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<Option<FileId>, 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);
}
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?;
if old_content.is_empty() {
return Ok(None);
}
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 {
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,
Err(()) => prev_content,
}
});
if new_content != old_content {
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)
}
pub fn compute_regions_to_format(
base_content: Option<&[u8]>,
current_content: &[u8],
) -> RegionsToFormat {
if current_content.is_empty() {
RegionsToFormat::LineRanges(vec![])
} else if let Some(base) = base_content {
compute_changed_ranges(base, current_content)
} else {
let line_count = compute_file_line_count(current_content);
RegionsToFormat::LineRanges(vec![LineRange {
first: 1,
last: line_count,
}])
}
}
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<Vec<u8>, ()> {
let mut vars: HashMap<&str, &str> = HashMap::new();
vars.insert("path", file_to_fix.repo_path.as_internal_file_string());
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(())
}
}
struct ToolConfig {
command: CommandNameAndArgs,
matcher: Box<dyn Matcher>,
enabled: bool,
line_range_arg: Option<String>,
run_tool_if_zero_line_ranges: bool,
}
struct ToolsConfig {
tools: Vec<ToolConfig>,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
struct RawToolConfig {
command: CommandNameAndArgs,
patterns: Vec<String>,
#[serde(default = "default_tool_enabled")]
enabled: bool,
#[serde(default)]
line_range_arg: Option<String>,
#[serde(default)]
run_tool_if_zero_line_ranges: bool,
}
fn default_tool_enabled() -> bool {
true
}
fn get_tools_config(
ui: &mut Ui,
settings: &UserSettings,
fileset_context: &FilesetParseContext,
) -> Result<ToolsConfig, CommandError> {
let mut tools: Vec<ToolConfig> = settings
.table_keys("fix.tools")
.sorted()
.map(|name| -> Result<ToolConfig, CommandError> {
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() {
assert_eq!(
compute_regions_to_format(None, b"a\nb\nc\n"),
RegionsToFormat::LineRanges(vec![line_range(1, 3)])
);
assert_eq!(
compute_regions_to_format(Some(b""), b"a\nb\nc\n"),
RegionsToFormat::LineRanges(vec![line_range(1, 3)])
);
assert_eq!(
compute_regions_to_format(Some(b"a\nB\nc\n"), b"a\nb\nc\n"),
RegionsToFormat::LineRanges(vec![line_range(2, 2)])
);
assert_eq!(
compute_regions_to_format(Some(b"a\nb\nc\nd\n"), b"a\nb\nc\n"),
RegionsToFormat::LineRanges(vec![])
);
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)])
);
assert_eq!(
compute_regions_to_format(Some(b"a\nb\nc\n"), b""),
RegionsToFormat::LineRanges(vec![])
);
}
}