diff options
author | Wilfred Hughes <me@wilfred.me.uk> | 2023-08-12 00:01:48 -0700 |
---|---|---|
committer | Wilfred Hughes <me@wilfred.me.uk> | 2023-08-15 09:01:15 -0700 |
commit | e0a14054539fa90f8defe67b4a46dec5e7a26c28 (patch) | |
tree | 666ca684613c8a0ad14b4c6646b5a592b76c2453 | |
parent | f06e95ca0207f7d2ca17039d9d3e8bcde7134342 (diff) |
Add the ability to parse conflict markers and diff the two files
-rw-r--r-- | CHANGELOG.md | 9 | ||||
-rw-r--r-- | manual/src/usage.md | 13 | ||||
-rw-r--r-- | src/conflicts.rs | 137 | ||||
-rw-r--r-- | src/files.rs | 10 | ||||
-rw-r--r-- | src/main.rs | 82 | ||||
-rw-r--r-- | src/options.rs | 37 |
6 files changed, 287 insertions, 1 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index beacf1277..68fd2bec1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ ## 0.50 (unreleased) +### Conflicts + +Difftastic now supports parsing files with conflict markers, enabling +you to diff the two conflicting file states. + +``` +$ difft file_with_conflicts.js +``` + ### Parsing Updated Elixir, Erlang, Go, Kotlin and Racket parsers. diff --git a/manual/src/usage.md b/manual/src/usage.md index 11db9cb2d..0824b8b58 100644 --- a/manual/src/usage.md +++ b/manual/src/usage.md @@ -28,6 +28,19 @@ You can read a file from stdin by specifying `-` as the file path. $ cat sample_files/before.js | difft - sample_files/after.js ``` +### Files With Conflicts + +*(Added in version 0.50.)* + +If you have a file with `<<<<<<<` conflict markers, you can pass it as +a single argument to difftastic. Difftastic will construct the two +file states and diff those. + +``` +$ difft sample_files/conflicts.el +``` + + ## Language Detection Difftastic guesses the language used based on the file extension, file diff --git a/src/conflicts.rs b/src/conflicts.rs new file mode 100644 index 000000000..f97ba1215 --- /dev/null +++ b/src/conflicts.rs @@ -0,0 +1,137 @@ +//! Apply conflict markers to obtain the original file contents. +//! +//! https://git-scm.com/docs/git-merge#Documentation/git-merge.txt-mergeconflictStyle + +use ConflictState::*; + +#[derive(Debug, Clone, Copy)] +enum ConflictState { + NoConflict, + Left, + Base, + Right, +} + +pub const START_LHS_MARKER: &str = "<<<<<<<"; +const START_BASE_MARKER: &str = "|||||||"; +const START_RHS_MARKER: &str = "======="; +const END_RHS_MARKER: &str = ">>>>>>>"; + +pub struct ConflictFiles { + pub lhs_name: String, + pub lhs_content: String, + pub rhs_name: String, + pub rhs_content: String, + pub num_conflicts: usize, +} + +/// Convert a string with conflict markers into the two conflicting +/// file contents. +pub fn apply_conflict_markers(s: &str) -> Result<ConflictFiles, String> { + let mut lhs_name = String::new(); + let mut rhs_name = String::new(); + + let mut lhs_content = String::with_capacity(s.len()); + let mut rhs_content = String::with_capacity(s.len()); + let mut num_conflicts = 0; + + let mut state = NoConflict; + let mut conflict_start_line = None; + for (i, line) in s.split_inclusive('\n').enumerate() { + if let Some(hunk_lhs_name) = line.strip_prefix(START_LHS_MARKER) { + state = Left; + num_conflicts += 1; + conflict_start_line = Some(i); + + let hunk_lhs_name = hunk_lhs_name.trim(); + if hunk_lhs_name.len() > lhs_name.len() { + lhs_name = hunk_lhs_name.to_owned(); + } + + continue; + } + if line.starts_with(START_BASE_MARKER) { + state = Base; + continue; + } + if line.starts_with(START_RHS_MARKER) { + state = Right; + continue; + } + if let Some(hunk_rhs_name) = line.strip_prefix(END_RHS_MARKER) { + state = NoConflict; + + let hunk_rhs_name = hunk_rhs_name.trim(); + if hunk_rhs_name.len() > rhs_name.len() { + rhs_name = hunk_rhs_name.to_owned(); + } + continue; + } + + match state { + NoConflict => { + lhs_content.push_str(line); + rhs_content.push_str(line); + } + Left => { + lhs_content.push_str(line); + } + Right => { + rhs_content.push_str(line); + } + Base => {} + } + } + + if matches!(state, NoConflict) { + Ok(ConflictFiles { + lhs_name, + lhs_content, + rhs_name, + rhs_content, + num_conflicts, + }) + } else { + let message = match conflict_start_line { + Some(line_i) => format!( + "Could not parse conflict markers, line {} has no matching {}.", + line_i, END_RHS_MARKER + ), + None => "Could not parse conflict markers.".to_owned(), + }; + Err(message) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_with_base() { + // Deliberately avoid a multiline string literal to avoid + // confusing text editors when we open this file. + let s = "before\n<<<<<<< Temporary merge branch 1\nnew in left\n||||||| merged common ancestors\noriginal\n=======\nnew in right\n>>>>>>> Temporary merge branch 2\nafter"; + + let conflict_files = apply_conflict_markers(s).unwrap(); + assert_eq!(conflict_files.lhs_content, "before\nnew in left\nafter"); + assert_eq!(conflict_files.rhs_content, "before\nnew in right\nafter"); + + assert_eq!(conflict_files.lhs_name, "Temporary merge branch 1"); + assert_eq!(conflict_files.rhs_name, "Temporary merge branch 2"); + } + + #[test] + fn test_without_base() { + // Deliberately avoid a multiline string literal to avoid + // confusing text editors when we open this file. + let s = "before\n<<<<<<< Temporary merge branch 1\nnew in left\n=======\nnew in right\n>>>>>>> Temporary merge branch 2\nafter"; + + let conflict_files = apply_conflict_markers(s).unwrap(); + assert_eq!(conflict_files.lhs_content, "before\nnew in left\nafter"); + assert_eq!(conflict_files.rhs_content, "before\nnew in right\nafter"); + + assert_eq!(conflict_files.lhs_name, "Temporary merge branch 1"); + assert_eq!(conflict_files.rhs_name, "Temporary merge branch 2"); + } +} diff --git a/src/files.rs b/src/files.rs index 1cad43540..5d4cd099b 100644 --- a/src/files.rs +++ b/src/files.rs @@ -13,6 +13,16 @@ use walkdir::WalkDir; use crate::exit_codes::EXIT_BAD_ARGUMENTS; use crate::options::FileArgument; +pub fn read_file_or_die(path: &FileArgument) -> Vec<u8> { + match read_file_arg(path) { + Ok(src) => src, + Err(e) => { + eprint_read_error(path, &e); + std::process::exit(EXIT_BAD_ARGUMENTS); + } + } +} + pub fn read_files_or_die( lhs_path: &FileArgument, rhs_path: &FileArgument, diff --git a/src/main.rs b/src/main.rs index 8eea8b07d..9503e9172 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,6 +23,7 @@ // correct. #![allow(clippy::mutable_key_type)] +mod conflicts; mod constants; mod diff; mod display; @@ -39,16 +40,20 @@ mod summary; #[macro_use] extern crate log; +use crate::conflicts::START_LHS_MARKER; use crate::diff::{dijkstra, unchanged}; use crate::display::hunks::{matched_pos_to_hunks, merge_adjacent}; +use crate::exit_codes::EXIT_BAD_ARGUMENTS; use crate::parse::guess_language::language_globs; use crate::parse::syntax; +use conflicts::apply_conflict_markers; use diff::changes::ChangeMap; use diff::dijkstra::ExceededGraphLimit; use display::context::opposite_positions; use exit_codes::{EXIT_FOUND_CHANGES, EXIT_SUCCESS}; use files::{ - guess_content, read_files_or_die, read_or_die, relative_paths_in_either, ProbableFileKind, + guess_content, read_file_or_die, read_files_or_die, read_or_die, relative_paths_in_either, + ProbableFileKind, }; use log::info; use mimalloc::MiMalloc; @@ -174,6 +179,31 @@ fn main() { println!(); } } + Mode::DiffFromConflicts { + display_path, + path, + diff_options, + display_options, + set_exit_code, + language_overrides, + } => { + let diff_result = diff_conflicts_file( + &display_path, + &path, + &display_options, + &diff_options, + &language_overrides, + ); + + print_diff_result(&display_options, &diff_result); + + let exit_code = if set_exit_code && diff_result.has_reportable_change() { + EXIT_FOUND_CHANGES + } else { + EXIT_SUCCESS + }; + std::process::exit(exit_code); + } Mode::Diff { diff_options, display_options, @@ -307,6 +337,56 @@ fn diff_file( ) } +fn diff_conflicts_file( + display_path: &str, + path: &FileArgument, + display_options: &DisplayOptions, + diff_options: &DiffOptions, + overrides: &[(glob::Pattern, LanguageOverride)], +) -> DiffResult { + let bytes = read_file_or_die(path); + let src = match guess_content(&bytes) { + ProbableFileKind::Text(src) => src, + ProbableFileKind::Binary => { + eprintln!("error: Expected a text file with conflict markers, got a binary file."); + std::process::exit(EXIT_BAD_ARGUMENTS); + } + }; + + let conflict_files = match apply_conflict_markers(&src) { + Ok(cf) => cf, + Err(msg) => { + eprintln!("error: {}", msg); + std::process::exit(EXIT_BAD_ARGUMENTS); + } + }; + + if conflict_files.num_conflicts == 0 { + eprintln!( + "warning: Expected a file with conflict markers {}, but none were found.", + START_LHS_MARKER, + ); + eprintln!("Difftastic parses conflict markers from a single file argument. Did you forget a second file argument?"); + } + + let extra_info = format!( + "Comparing '{}' with '{}'", + conflict_files.lhs_name, conflict_files.rhs_name + ); + + diff_file_content( + display_path, + Some(extra_info), + path, + path, + &conflict_files.lhs_content, + &conflict_files.rhs_content, + display_options, + diff_options, + overrides, + ) +} + fn check_only_text( file_format: &FileFormat, display_path: &str, diff --git a/src/options.rs b/src/options.rs index 8752f5549..e0949e286 100644 --- a/src/options.rs +++ b/src/options.rs @@ -95,6 +95,10 @@ fn app() -> clap::Command<'static> { "$ ", env!("CARGO_BIN_NAME"), " old/ new/\n\n", + "If you have a file with conflict markers, you can pass it as a single argument. Difftastic will diff the two conflicting file states.\n\n", + "$ ", + env!("CARGO_BIN_NAME"), + " file_with_conflicts.js\n\n", "Difftastic can also be invoked with 7 arguments in the format that GIT_EXTERNAL_DIFF expects.\n\n", "See the full manual at: https://difftastic.wilfred.me.uk/") ) @@ -357,6 +361,15 @@ pub enum Mode { /// If this file has been renamed, the name it had previously. old_path: Option<String>, }, + DiffFromConflicts { + diff_options: DiffOptions, + display_options: DisplayOptions, + set_exit_code: bool, + language_overrides: Vec<(glob::Pattern, LanguageOverride)>, + path: FileArgument, + /// The path that we show to the user. + display_path: String, + }, ListLanguages { use_color: bool, language_overrides: Vec<(glob::Pattern, LanguageOverride)>, @@ -613,6 +626,30 @@ pub fn parse_args() -> Mode { true, ) } + [path] => { + let display_options = DisplayOptions { + background_color, + use_color, + print_unchanged, + tab_width, + display_mode, + display_width, + num_context_lines, + syntax_highlight, + in_vcs: true, + }; + + let display_path = path.to_string_lossy().to_string(); + let path = FileArgument::from_path_argument(path); + return Mode::DiffFromConflicts { + display_path, + path, + diff_options, + display_options, + set_exit_code, + language_overrides, + }; + } _ => { if !args.is_empty() { eprintln!( |