1use std::borrow::Cow;
16use std::cmp::max;
17use std::cmp::min;
18use std::future;
19use std::io;
20use std::iter;
21use std::ops::Range;
22use std::path::Path;
23use std::path::PathBuf;
24
25use bstr::BStr;
26use bstr::BString;
27use clap_complete::ArgValueCandidates;
28use futures::StreamExt as _;
29use futures::TryStreamExt as _;
30use futures::stream::BoxStream;
31use itertools::Itertools as _;
32use jj_lib::backend::BackendError;
33use jj_lib::backend::BackendResult;
34use jj_lib::backend::CommitId;
35use jj_lib::backend::CopyRecord;
36use jj_lib::backend::MergedTreeValue;
37use jj_lib::backend::MergedTreeValueExt as _;
38use jj_lib::backend::TreeValue;
39use jj_lib::commit::Commit;
40use jj_lib::config::ConfigGetError;
41use jj_lib::config::ConfigGetResultExt as _;
42use jj_lib::conflict_labels::ConflictLabels;
43use jj_lib::conflicts::ConflictMarkerStyle;
44use jj_lib::conflicts::ConflictMaterializeOptions;
45use jj_lib::conflicts::MaterializedTreeDiffEntry;
46use jj_lib::conflicts::MaterializedTreeValue;
47use jj_lib::conflicts::materialize_merge_result_to_bytes;
48use jj_lib::conflicts::materialized_diff_stream;
49use jj_lib::copies::CopiesTreeDiffEntry;
50use jj_lib::copies::CopiesTreeDiffEntryPath;
51use jj_lib::copies::CopyOperation;
52use jj_lib::copies::CopyRecords;
53use jj_lib::diff::ContentDiff;
54use jj_lib::diff::DiffHunk;
55use jj_lib::diff::DiffHunkKind;
56use jj_lib::diff_presentation::DiffTokenType;
57use jj_lib::diff_presentation::FileContent;
58use jj_lib::diff_presentation::LineCompareMode;
59use jj_lib::diff_presentation::diff_by_line;
60use jj_lib::diff_presentation::file_content_for_diff;
61use jj_lib::diff_presentation::unified::DiffLineType;
62use jj_lib::diff_presentation::unified::UnifiedDiffError;
63use jj_lib::diff_presentation::unified::git_diff_part;
64use jj_lib::diff_presentation::unified::unified_diff_hunks;
65use jj_lib::diff_presentation::unzip_diff_hunks_to_lines;
66use jj_lib::files;
67use jj_lib::files::ConflictDiffHunk;
68use jj_lib::files::DiffLineHunkSide;
69use jj_lib::files::DiffLineIterator;
70use jj_lib::files::DiffLineNumber;
71use jj_lib::matchers::Matcher;
72use jj_lib::merge::Diff;
73use jj_lib::merge::Merge;
74use jj_lib::merge::MergeBuilder;
75use jj_lib::merged_tree::MergedTree;
76use jj_lib::repo::Repo;
77use jj_lib::repo_path::InvalidRepoPathError;
78use jj_lib::repo_path::RepoPath;
79use jj_lib::rewrite::rebase_to_dest_parent;
80use jj_lib::settings::UserSettings;
81use jj_lib::store::Store;
82use jj_lib::ui_path::RepoPathUiConverter;
83use thiserror::Error;
84use tracing::instrument;
85use unicode_width::UnicodeWidthStr as _;
86
87use crate::command_error::CommandError;
88use crate::command_error::cli_error;
89use crate::commit_templater;
90use crate::config::CommandNameAndArgs;
91use crate::formatter::Formatter;
92use crate::formatter::FormatterExt as _;
93use crate::merge_tools;
94use crate::merge_tools::DiffGenerateError;
95use crate::merge_tools::DiffToolMode;
96use crate::merge_tools::ExternalMergeTool;
97use crate::merge_tools::generate_diff;
98use crate::merge_tools::invoke_external_diff;
99use crate::merge_tools::new_utf8_temp_dir;
100use crate::templater::TemplateRenderer;
101use crate::text_util;
102use crate::ui::Ui;
103
104#[derive(clap::Args, Clone, Debug)]
105#[command(next_help_heading = "Diff Formatting Options")]
106#[command(group(clap::ArgGroup::new("short-format").args(&["summary", "stat", "types", "name_only"])))]
107#[command(group(clap::ArgGroup::new("long-format").args(&["git", "color_words"])))]
108pub struct DiffFormatArgs {
109 #[arg(long, short)]
111 pub summary: bool,
112
113 #[arg(long)]
115 pub stat: bool,
116
117 #[arg(long)]
125 pub types: bool,
126
127 #[arg(long)]
132 pub name_only: bool,
133
134 #[arg(long)]
136 pub git: bool,
137
138 #[arg(long)]
140 pub color_words: bool,
141
142 #[arg(long)]
147 #[arg(add = ArgValueCandidates::new(crate::complete::diff_formatters))]
148 pub tool: Option<String>,
149
150 #[arg(long)]
152 context: Option<usize>,
153
154 #[arg(long)] ignore_all_space: bool,
158
159 #[arg(long, conflicts_with = "ignore_all_space")] ignore_space_change: bool,
162}
163
164#[derive(Clone, Debug, Eq, PartialEq)]
165pub enum DiffFormat {
166 Summary,
168 Stat(Box<DiffStatOptions>),
169 Types,
170 NameOnly,
171 Git(Box<UnifiedDiffOptions>),
172 ColorWords(Box<ColorWordsDiffOptions>),
173 Tool(Box<ExternalMergeTool>),
174}
175
176#[derive(Clone, Copy, Debug, Eq, PartialEq)]
177enum BuiltinFormatKind {
178 Summary,
179 Stat,
180 Types,
181 NameOnly,
182 Git,
183 ColorWords,
184}
185
186impl BuiltinFormatKind {
187 const ALL_VARIANTS: &[Self] = &[
191 Self::Summary,
192 Self::Stat,
193 Self::Types,
194 Self::NameOnly,
195 Self::Git,
196 Self::ColorWords,
197 ];
198
199 fn from_name(name: &str) -> Result<Self, String> {
200 match name {
201 "summary" => Ok(Self::Summary),
202 "stat" => Ok(Self::Stat),
203 "types" => Ok(Self::Types),
204 "name-only" => Ok(Self::NameOnly),
205 "git" => Ok(Self::Git),
206 "color-words" => Ok(Self::ColorWords),
207 _ => Err(format!("Invalid builtin diff format: {name}")),
208 }
209 }
210
211 fn short_from_args(args: &DiffFormatArgs) -> Option<Self> {
212 if args.summary {
213 Some(Self::Summary)
214 } else if args.stat {
215 Some(Self::Stat)
216 } else if args.types {
217 Some(Self::Types)
218 } else if args.name_only {
219 Some(Self::NameOnly)
220 } else {
221 None
222 }
223 }
224
225 fn long_from_args(args: &DiffFormatArgs) -> Option<Self> {
226 if args.git {
227 Some(Self::Git)
228 } else if args.color_words {
229 Some(Self::ColorWords)
230 } else {
231 None
232 }
233 }
234
235 fn is_short(self) -> bool {
236 match self {
237 Self::Summary | Self::Stat | Self::Types | Self::NameOnly => true,
238 Self::Git | Self::ColorWords => false,
239 }
240 }
241
242 fn to_arg_name(self) -> &'static str {
243 match self {
244 Self::Summary => "summary",
245 Self::Stat => "stat",
246 Self::Types => "types",
247 Self::NameOnly => "name-only",
248 Self::Git => "git",
249 Self::ColorWords => "color-words",
250 }
251 }
252
253 fn to_format(
254 self,
255 settings: &UserSettings,
256 args: &DiffFormatArgs,
257 ) -> Result<DiffFormat, ConfigGetError> {
258 match self {
259 Self::Summary => Ok(DiffFormat::Summary),
260 Self::Stat => {
261 let mut options = DiffStatOptions::from_settings(settings)?;
262 options.merge_args(args);
263 Ok(DiffFormat::Stat(Box::new(options)))
264 }
265 Self::Types => Ok(DiffFormat::Types),
266 Self::NameOnly => Ok(DiffFormat::NameOnly),
267 Self::Git => {
268 let mut options = UnifiedDiffOptions::from_settings(settings)?;
269 options.merge_args(args);
270 Ok(DiffFormat::Git(Box::new(options)))
271 }
272 Self::ColorWords => {
273 let mut options = ColorWordsDiffOptions::from_settings(settings)?;
274 options.merge_args(args);
275 Ok(DiffFormat::ColorWords(Box::new(options)))
276 }
277 }
278 }
279}
280
281pub fn all_builtin_diff_format_names() -> Vec<String> {
283 BuiltinFormatKind::ALL_VARIANTS
284 .iter()
285 .map(|kind| format!(":{}", kind.to_arg_name()))
286 .collect()
287}
288
289fn diff_formatter_tool(
290 settings: &UserSettings,
291 name: &str,
292) -> Result<Option<ExternalMergeTool>, CommandError> {
293 let maybe_tool = merge_tools::get_external_tool_config(settings, name)?;
294 if let Some(tool) = &maybe_tool
295 && tool.diff_args.is_empty()
296 {
297 return Err(cli_error(format!(
298 "The tool `{name}` cannot be used for diff formatting"
299 )));
300 }
301 Ok(maybe_tool)
302}
303
304pub fn diff_formats_for(
306 settings: &UserSettings,
307 args: &DiffFormatArgs,
308) -> Result<Vec<DiffFormat>, CommandError> {
309 let formats = diff_formats_from_args(settings, args)?;
310 if formats.iter().all(|f| f.is_none()) {
311 Ok(vec![default_diff_format(settings, args)?])
312 } else {
313 Ok(formats.into_iter().flatten().collect())
314 }
315}
316
317pub fn diff_formats_for_log(
320 settings: &UserSettings,
321 args: &DiffFormatArgs,
322 patch: bool,
323) -> Result<Vec<DiffFormat>, CommandError> {
324 let [short_format, mut long_format] = diff_formats_from_args(settings, args)?;
325 if patch && long_format.is_none() {
327 let default_format = default_diff_format(settings, args)?;
330 if short_format.as_ref() != Some(&default_format) {
331 long_format = Some(default_format);
332 }
333 }
334 Ok([short_format, long_format].into_iter().flatten().collect())
335}
336
337fn diff_formats_from_args(
338 settings: &UserSettings,
339 args: &DiffFormatArgs,
340) -> Result<[Option<DiffFormat>; 2], CommandError> {
341 let short_kind = BuiltinFormatKind::short_from_args(args);
342 let long_kind = BuiltinFormatKind::long_from_args(args);
343 let mut short_format = short_kind
344 .map(|kind| kind.to_format(settings, args))
345 .transpose()?;
346 let mut long_format = long_kind
347 .map(|kind| kind.to_format(settings, args))
348 .transpose()?;
349 if let Some(name) = &args.tool {
350 let ensure_new = |old_kind: Option<BuiltinFormatKind>| match old_kind {
351 Some(old) => Err(cli_error(format!(
352 "--tool={name} cannot be used with --{old}",
353 old = old.to_arg_name()
354 ))),
355 None => Ok(()),
356 };
357 if let Some(name) = name.strip_prefix(':') {
358 let kind = BuiltinFormatKind::from_name(name).map_err(cli_error)?;
359 let format = kind.to_format(settings, args)?;
360 if kind.is_short() {
361 ensure_new(short_kind)?;
362 short_format = Some(format);
363 } else {
364 ensure_new(long_kind)?;
365 long_format = Some(format);
366 }
367 } else {
368 ensure_new(long_kind)?;
369 let tool = diff_formatter_tool(settings, name)?
370 .unwrap_or_else(|| ExternalMergeTool::with_program(name));
371 long_format = Some(DiffFormat::Tool(Box::new(tool)));
372 }
373 }
374 Ok([short_format, long_format])
375}
376
377fn default_diff_format(
378 settings: &UserSettings,
379 args: &DiffFormatArgs,
380) -> Result<DiffFormat, CommandError> {
381 let tool_args: CommandNameAndArgs = settings.get("ui.diff-formatter")?;
382 if let Some(name) = tool_args.as_str().and_then(|s| s.strip_prefix(':')) {
383 Ok(BuiltinFormatKind::from_name(name)
384 .map_err(|err| ConfigGetError::Type {
385 name: "ui.diff-formatter".to_owned(),
386 error: err.into(),
387 source_path: None,
388 })?
389 .to_format(settings, args)?)
390 } else {
391 let tool = if let Some(name) = tool_args.as_str() {
392 diff_formatter_tool(settings, name)?
393 } else {
394 None
395 }
396 .unwrap_or_else(|| ExternalMergeTool::with_diff_args(&tool_args));
397 Ok(DiffFormat::Tool(Box::new(tool)))
398 }
399}
400
401#[derive(Debug, Error)]
402pub enum DiffRenderError {
403 #[error("Failed to generate diff")]
404 DiffGenerate(#[source] DiffGenerateError),
405 #[error(transparent)]
406 Backend(#[from] BackendError),
407 #[error("Access denied to {path}")]
408 AccessDenied {
409 path: String,
410 source: Box<dyn std::error::Error + Send + Sync>,
411 },
412 #[error(transparent)]
413 InvalidRepoPath(#[from] InvalidRepoPathError),
414 #[error(transparent)]
415 Io(#[from] io::Error),
416}
417
418impl From<UnifiedDiffError> for DiffRenderError {
419 fn from(value: UnifiedDiffError) -> Self {
420 match value {
421 UnifiedDiffError::Backend(error) => Self::Backend(error),
422 UnifiedDiffError::AccessDenied { path, source } => Self::AccessDenied { path, source },
423 }
424 }
425}
426
427pub struct DiffRenderer<'a> {
429 repo: &'a dyn Repo,
430 path_converter: &'a RepoPathUiConverter,
431 conflict_marker_style: ConflictMarkerStyle,
432 formats: Vec<DiffFormat>,
433}
434
435impl<'a> DiffRenderer<'a> {
436 pub fn new(
437 repo: &'a dyn Repo,
438 path_converter: &'a RepoPathUiConverter,
439 conflict_marker_style: ConflictMarkerStyle,
440 formats: Vec<DiffFormat>,
441 ) -> Self {
442 Self {
443 repo,
444 path_converter,
445 conflict_marker_style,
446 formats,
447 }
448 }
449
450 pub async fn show_diff(
452 &self,
453 ui: &Ui, formatter: &mut dyn Formatter,
455 trees: Diff<&MergedTree>,
456 matcher: &dyn Matcher,
457 copy_records: &CopyRecords,
458 width: usize,
459 ) -> Result<(), DiffRenderError> {
460 let mut formatter = formatter.labeled("diff");
461 self.show_diff_trees(ui, *formatter, trees, matcher, copy_records, width)
462 .await
463 }
464
465 async fn show_diff_trees(
466 &self,
467 ui: &Ui,
468 formatter: &mut dyn Formatter,
469 trees: Diff<&MergedTree>,
470 matcher: &dyn Matcher,
471 copy_records: &CopyRecords,
472 width: usize,
473 ) -> Result<(), DiffRenderError> {
474 let diff_stream = || {
475 trees
476 .before
477 .diff_stream_with_copies(trees.after, matcher, copy_records)
478 };
479 let conflict_labels = trees.map(|tree| tree.labels());
480
481 let store = self.repo.store();
482 let path_converter = self.path_converter;
483 for format in &self.formats {
484 match format {
485 DiffFormat::Summary => {
486 let tree_diff = diff_stream();
487 show_diff_summary(*formatter.labeled("summary"), tree_diff, path_converter)
488 .await?;
489 }
490 DiffFormat::Stat(options) => {
491 let tree_diff = diff_stream();
492 let stats =
493 DiffStats::calculate(store, tree_diff, options, self.conflict_marker_style)
494 .await?;
495 show_diff_stats(
496 *formatter.labeled("stat"),
497 &stats,
498 path_converter,
499 width,
500 options,
501 )?;
502 }
503 DiffFormat::Types => {
504 let tree_diff = diff_stream();
505 show_types(*formatter.labeled("types"), tree_diff, path_converter).await?;
506 }
507 DiffFormat::NameOnly => {
508 let tree_diff = diff_stream();
509 show_names(*formatter.labeled("name_only"), tree_diff, path_converter).await?;
510 }
511 DiffFormat::Git(options) => {
512 let tree_diff = diff_stream();
513 show_git_diff(
514 *formatter.labeled("git"),
515 store,
516 tree_diff,
517 conflict_labels,
518 options,
519 self.conflict_marker_style,
520 )
521 .await?;
522 }
523 DiffFormat::ColorWords(options) => {
524 let tree_diff = diff_stream();
525 show_color_words_diff(
526 *formatter.labeled("color_words"),
527 store,
528 tree_diff,
529 conflict_labels,
530 path_converter,
531 options,
532 self.conflict_marker_style,
533 )
534 .await?;
535 }
536 DiffFormat::Tool(tool) => {
537 match tool.diff_invocation_mode {
538 DiffToolMode::FileByFile => {
539 let tree_diff = diff_stream();
540 show_file_by_file_diff(
541 ui,
542 formatter,
543 store,
544 tree_diff,
545 conflict_labels,
546 path_converter,
547 tool,
548 self.conflict_marker_style,
549 width,
550 )
551 .await
552 }
553 DiffToolMode::Dir => {
554 let mut writer = formatter.raw()?;
555 generate_diff(
556 ui,
557 writer.as_mut(),
558 trees,
559 matcher,
560 tool,
561 self.conflict_marker_style,
562 width,
563 )
564 .await
565 .map_err(DiffRenderError::DiffGenerate)
566 }
567 }?;
568 }
569 }
570 }
571 Ok(())
572 }
573
574 fn show_diff_commit_descriptions(
575 &self,
576 formatter: &mut dyn Formatter,
577 descriptions: Diff<&Merge<&str>>,
578 ) -> Result<(), DiffRenderError> {
579 if !descriptions.is_changed() {
580 return Ok(());
581 }
582 const DUMMY_PATH: &str = "JJ-COMMIT-DESCRIPTION";
583 let materialize_options = ConflictMaterializeOptions {
584 marker_style: self.conflict_marker_style,
585 marker_len: None,
586 merge: self.repo.store().merge_options().clone(),
587 };
588 for format in &self.formats {
589 match format {
590 DiffFormat::Summary
593 | DiffFormat::Stat(_)
594 | DiffFormat::Types
595 | DiffFormat::NameOnly => {}
596 DiffFormat::Git(options) => {
597 show_git_diff_texts(
599 formatter,
600 Diff::new(DUMMY_PATH, DUMMY_PATH),
601 descriptions,
602 options,
603 &materialize_options,
604 )?;
605 }
606 DiffFormat::ColorWords(options) => {
607 writeln!(formatter.labeled("header"), "Modified commit description:")?;
608 show_color_words_diff_hunks(
609 formatter,
610 descriptions,
611 Diff::new(&ConflictLabels::unlabeled(), &ConflictLabels::unlabeled()),
612 options,
613 &materialize_options,
614 )?;
615 }
616 DiffFormat::Tool(_) => {
617 }
619 }
620 }
621 Ok(())
622 }
623
624 pub async fn show_inter_diff(
628 &self,
629 ui: &Ui,
630 formatter: &mut dyn Formatter,
631 from_commits: &[Commit],
632 to_commit: &Commit,
633 matcher: &dyn Matcher,
634 width: usize,
635 ) -> Result<(), DiffRenderError> {
636 let mut formatter = formatter.labeled("diff");
637 let from_description = if from_commits.is_empty() {
638 Merge::resolved("")
639 } else {
640 MergeBuilder::from_iter(itertools::intersperse(
642 from_commits.iter().map(|c| c.description()),
643 "",
644 ))
645 .build()
646 .simplify()
647 };
648 let to_description = Merge::resolved(to_commit.description());
649 let from_tree = rebase_to_dest_parent(self.repo, from_commits, to_commit).await?;
650 let to_tree = to_commit.tree();
651 let copy_records = CopyRecords::default(); self.show_diff_commit_descriptions(
653 *formatter,
654 Diff::new(&from_description, &to_description),
655 )?;
656 self.show_diff_trees(
657 ui,
658 *formatter,
659 Diff::new(&from_tree, &to_tree),
660 matcher,
661 ©_records,
662 width,
663 )
664 .await
665 }
666
667 pub async fn show_patch(
669 &self,
670 ui: &Ui,
671 formatter: &mut dyn Formatter,
672 commit: &Commit,
673 matcher: &dyn Matcher,
674 width: usize,
675 ) -> Result<(), DiffRenderError> {
676 let from_tree = commit.parent_tree(self.repo).await?;
677 let to_tree = commit.tree();
678 let mut copy_records = CopyRecords::default();
679 for parent_id in commit.parent_ids() {
680 let records =
681 get_copy_records(self.repo.store(), parent_id, commit.id(), matcher).await?;
682 copy_records.add_records(records);
683 }
684 self.show_diff(
685 ui,
686 formatter,
687 Diff::new(&from_tree, &to_tree),
688 matcher,
689 ©_records,
690 width,
691 )
692 .await
693 }
694}
695
696pub async fn get_copy_records(
697 store: &Store,
698 root: &CommitId,
699 head: &CommitId,
700 matcher: &dyn Matcher,
701) -> BackendResult<Vec<CopyRecord>> {
702 let stream = store.get_copy_records(None, root, head)?;
704 stream
706 .try_filter(|record| future::ready(matcher.matches(&record.target)))
707 .try_collect()
708 .await
709}
710
711#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Deserialize)]
713#[serde(rename_all = "kebab-case")]
714pub enum ConflictDiffMethod {
715 #[default]
717 Materialize,
718 Pair,
720}
721
722#[derive(Clone, Debug, Default, Eq, PartialEq)]
723pub struct LineDiffOptions {
724 pub compare_mode: LineCompareMode,
726 }
728
729impl LineDiffOptions {
730 fn merge_args(&mut self, args: &DiffFormatArgs) {
731 self.compare_mode = if args.ignore_all_space {
732 LineCompareMode::IgnoreAllSpace
733 } else if args.ignore_space_change {
734 LineCompareMode::IgnoreSpaceChange
735 } else {
736 LineCompareMode::Exact
737 };
738 }
739}
740
741#[derive(Clone, Debug, Eq, PartialEq)]
742pub struct ColorWordsDiffOptions {
743 pub conflict: ConflictDiffMethod,
745 pub context: usize,
747 pub line_diff: LineDiffOptions,
749 pub max_inline_alternation: Option<usize>,
751}
752
753impl ColorWordsDiffOptions {
754 pub fn from_settings(settings: &UserSettings) -> Result<Self, ConfigGetError> {
755 let max_inline_alternation = {
756 let name = "diff.color-words.max-inline-alternation";
757 match settings.get_int(name)? {
758 -1 => None, n => Some(usize::try_from(n).map_err(|err| ConfigGetError::Type {
760 name: name.to_owned(),
761 error: err.into(),
762 source_path: None,
763 })?),
764 }
765 };
766 Ok(Self {
767 conflict: settings.get("diff.color-words.conflict")?,
768 context: settings.get("diff.color-words.context")?,
769 line_diff: LineDiffOptions::default(),
770 max_inline_alternation,
771 })
772 }
773
774 fn merge_args(&mut self, args: &DiffFormatArgs) {
775 if let Some(context) = args.context {
776 self.context = context;
777 }
778 self.line_diff.merge_args(args);
779 }
780}
781
782fn show_color_words_diff_hunks<T: AsRef<[u8]>>(
783 formatter: &mut dyn Formatter,
784 contents: Diff<&Merge<T>>,
785 conflict_labels: Diff<&ConflictLabels>,
786 options: &ColorWordsDiffOptions,
787 materialize_options: &ConflictMaterializeOptions,
788) -> io::Result<()> {
789 let line_number = DiffLineNumber { left: 1, right: 1 };
790 let labels = Diff::new("removed", "added");
791 if let (Some(left), Some(right)) = (contents.before.as_resolved(), contents.after.as_resolved())
792 {
793 let contents = Diff::new(left, right).map(BStr::new);
794 show_color_words_resolved_hunks(formatter, contents, line_number, labels, options)?;
795 return Ok(());
796 }
797 match options.conflict {
798 ConflictDiffMethod::Materialize => {
799 let contents = contents.zip(conflict_labels).map(|(side, labels)| {
800 materialize_merge_result_to_bytes(side, labels, materialize_options)
801 });
802 show_color_words_resolved_hunks(
803 formatter,
804 contents.as_ref().map(BStr::new),
805 line_number,
806 labels,
807 options,
808 )?;
809 }
810 ConflictDiffMethod::Pair => {
811 let contents = contents.map(|side| files::merge(side, &materialize_options.merge));
812 show_color_words_conflict_hunks(
813 formatter,
814 contents.as_ref(),
815 line_number,
816 labels,
817 options,
818 )?;
819 }
820 }
821 Ok(())
822}
823
824fn show_color_words_conflict_hunks(
825 formatter: &mut dyn Formatter,
826 contents: Diff<&Merge<BString>>,
827 mut line_number: DiffLineNumber,
828 labels: Diff<&str>,
829 options: &ColorWordsDiffOptions,
830) -> io::Result<DiffLineNumber> {
831 let num_lefts = contents.before.as_slice().len();
832 let line_diff = diff_by_line(
833 itertools::chain(contents.before, contents.after),
834 &options.line_diff.compare_mode,
835 );
836 let mut contexts: Vec<Diff<&BStr>> = Vec::new();
840 let mut emitted = false;
841
842 for hunk in files::conflict_diff_hunks(line_diff.hunks(), num_lefts) {
843 match hunk.kind {
844 DiffHunkKind::Matching => {
847 contexts.push(Diff::new(hunk.lefts.first(), hunk.rights.first()));
848 }
849 DiffHunkKind::Different => {
850 let num_after = if emitted { options.context } else { 0 };
851 let num_before = options.context;
852 line_number = show_color_words_context_lines(
853 formatter,
854 &contexts,
855 line_number,
856 labels,
857 options,
858 num_after,
859 num_before,
860 )?;
861 contexts.clear();
862 emitted = true;
863 line_number = if let (Some(&left), Some(&right)) =
864 (hunk.lefts.as_resolved(), hunk.rights.as_resolved())
865 {
866 show_color_words_diff_lines(
867 formatter,
868 Diff::new(left, right),
869 line_number,
870 labels,
871 options,
872 )?
873 } else {
874 show_color_words_unresolved_hunk(
875 formatter,
876 &hunk,
877 line_number,
878 labels,
879 options,
880 )?
881 }
882 }
883 }
884 }
885
886 let num_after = if emitted { options.context } else { 0 };
887 let num_before = 0;
888 show_color_words_context_lines(
889 formatter,
890 &contexts,
891 line_number,
892 labels,
893 options,
894 num_after,
895 num_before,
896 )
897}
898
899fn show_color_words_unresolved_hunk(
900 formatter: &mut dyn Formatter,
901 hunk: &ConflictDiffHunk,
902 line_number: DiffLineNumber,
903 labels: Diff<&str>,
904 options: &ColorWordsDiffOptions,
905) -> io::Result<DiffLineNumber> {
906 let hunk_desc = if hunk.lefts.is_resolved() {
907 "Created conflict"
908 } else if hunk.rights.is_resolved() {
909 "Resolved conflict"
910 } else {
911 "Modified conflict"
912 };
913 writeln!(formatter.labeled("hunk_header"), "<<<<<<< {hunk_desc}")?;
914
915 let num_terms = max(hunk.lefts.as_slice().len(), hunk.rights.as_slice().len());
920 let lefts = hunk.lefts.iter().enumerate();
921 let rights = hunk.rights.iter().enumerate();
922 let padded = iter::zip(
923 lefts.chain(iter::repeat((0, hunk.lefts.first()))),
924 rights.chain(iter::repeat((0, hunk.rights.first()))),
925 )
926 .take(num_terms);
927 let mut max_line_number = line_number;
928 for (i, ((left_index, &left_content), (right_index, &right_content))) in padded.enumerate() {
929 let positive = i % 2 == 0;
930 writeln!(
931 formatter.labeled("hunk_header"),
932 "{sep} left {left_name} #{left_index} to right {right_name} #{right_index}",
933 sep = if positive { "+++++++" } else { "-------" },
934 left_name = if left_index % 2 == 0 { "side" } else { "base" },
936 left_index = left_index / 2 + 1,
937 right_name = if right_index % 2 == 0 { "side" } else { "base" },
938 right_index = right_index / 2 + 1,
939 )?;
940 let contents = Diff::new(left_content, right_content);
941 let labels = match positive {
942 true => labels,
943 false => labels.invert(),
944 };
945 let new_line_number =
947 show_color_words_resolved_hunks(formatter, contents, line_number, labels, options)?;
948 max_line_number.left = max(max_line_number.left, new_line_number.left);
952 max_line_number.right = max(max_line_number.right, new_line_number.right);
953 }
954
955 writeln!(formatter.labeled("hunk_header"), ">>>>>>> Conflict ends")?;
956 Ok(max_line_number)
957}
958
959fn show_color_words_resolved_hunks(
960 formatter: &mut dyn Formatter,
961 contents: Diff<&BStr>,
962 mut line_number: DiffLineNumber,
963 labels: Diff<&str>,
964 options: &ColorWordsDiffOptions,
965) -> io::Result<DiffLineNumber> {
966 let line_diff = diff_by_line(contents.into_array(), &options.line_diff.compare_mode);
967 let mut context: Option<Diff<&BStr>> = None;
969 let mut emitted = false;
970
971 for hunk in line_diff.hunks() {
972 let &[left, right] = hunk.contents.as_slice() else {
973 panic!("hunk contents should have two sides")
974 };
975 let hunk_contents = Diff::new(left, right);
976 match hunk.kind {
977 DiffHunkKind::Matching => {
978 context = Some(hunk_contents);
979 }
980 DiffHunkKind::Different => {
981 let num_after = if emitted { options.context } else { 0 };
982 let num_before = options.context;
983 line_number = show_color_words_context_lines(
984 formatter,
985 context.as_slice(),
986 line_number,
987 labels,
988 options,
989 num_after,
990 num_before,
991 )?;
992 context = None;
993 emitted = true;
994 line_number = show_color_words_diff_lines(
995 formatter,
996 hunk_contents,
997 line_number,
998 labels,
999 options,
1000 )?;
1001 }
1002 }
1003 }
1004
1005 let num_after = if emitted { options.context } else { 0 };
1006 let num_before = 0;
1007 show_color_words_context_lines(
1008 formatter,
1009 context.as_slice(),
1010 line_number,
1011 labels,
1012 options,
1013 num_after,
1014 num_before,
1015 )
1016}
1017
1018fn show_color_words_context_lines(
1020 formatter: &mut dyn Formatter,
1021 contexts: &[Diff<&BStr>],
1022 mut line_number: DiffLineNumber,
1023 labels: Diff<&str>,
1024 options: &ColorWordsDiffOptions,
1025 num_after: usize,
1026 num_before: usize,
1027) -> io::Result<DiffLineNumber> {
1028 const SKIPPED_CONTEXT_LINE: &str = " ...\n";
1029 let extract = |after: bool| -> (Vec<&[u8]>, Vec<&[u8]>, u32) {
1030 let mut lines = contexts
1031 .iter()
1032 .map(|contents| {
1033 if after {
1034 contents.after
1035 } else {
1036 contents.before
1037 }
1038 })
1039 .flat_map(|side| side.split_inclusive(|b| *b == b'\n'))
1040 .fuse();
1041 let after_lines = lines.by_ref().take(num_after).collect();
1042 let before_lines = lines.by_ref().rev().take(num_before + 1).collect();
1043 let num_skipped: u32 = lines.count().try_into().unwrap();
1044 (after_lines, before_lines, num_skipped)
1045 };
1046 let show = |formatter: &mut dyn Formatter,
1047 [left_lines, right_lines]: [&[&[u8]]; 2],
1048 mut line_number: DiffLineNumber| {
1049 let mut formatter = formatter.labeled("context");
1052 if left_lines == right_lines {
1053 for line in left_lines {
1054 show_color_words_line_number(
1055 *formatter,
1056 Diff::new(Some(line_number.left), Some(line_number.right)),
1057 labels,
1058 )?;
1059 show_color_words_inline_hunks(
1060 *formatter,
1061 &[(DiffLineHunkSide::Both, line.as_ref())],
1062 labels,
1063 )?;
1064 line_number.left += 1;
1065 line_number.right += 1;
1066 }
1067 Ok(line_number)
1068 } else {
1069 let left = left_lines.concat();
1070 let right = right_lines.concat();
1071 show_color_words_diff_lines(
1072 *formatter,
1073 Diff::new(&left, &right).map(BStr::new),
1074 line_number,
1075 labels,
1076 options,
1077 )
1078 }
1079 };
1080
1081 let (left_after, mut left_before, num_left_skipped) = extract(false);
1082 let (right_after, mut right_before, num_right_skipped) = extract(true);
1083 line_number = show(formatter, [&left_after, &right_after], line_number)?;
1084 if num_left_skipped > 0 || num_right_skipped > 0 {
1085 write!(formatter, "{SKIPPED_CONTEXT_LINE}")?;
1086 line_number.left += num_left_skipped;
1087 line_number.right += num_right_skipped;
1088 if left_before.len() > num_before {
1089 left_before.pop();
1090 line_number.left += 1;
1091 }
1092 if right_before.len() > num_before {
1093 right_before.pop();
1094 line_number.right += 1;
1095 }
1096 }
1097 left_before.reverse();
1098 right_before.reverse();
1099 line_number = show(formatter, [&left_before, &right_before], line_number)?;
1100 Ok(line_number)
1101}
1102
1103fn show_color_words_diff_lines(
1104 formatter: &mut dyn Formatter,
1105 contents: Diff<&BStr>,
1106 mut line_number: DiffLineNumber,
1107 labels: Diff<&str>,
1108 options: &ColorWordsDiffOptions,
1109) -> io::Result<DiffLineNumber> {
1110 let word_diff_hunks = ContentDiff::by_word(contents.into_array())
1111 .hunks()
1112 .collect_vec();
1113 let can_inline = if formatter.maybe_color() {
1114 match options.max_inline_alternation {
1115 None => true, Some(0) => false, Some(max_num) => {
1118 let groups = split_diff_hunks_by_matching_newline(&word_diff_hunks);
1119 groups.map(count_diff_alternation).max().unwrap_or(0) <= max_num
1120 }
1121 }
1122 } else {
1123 false
1126 };
1127 if can_inline {
1128 let mut diff_line_iter =
1129 DiffLineIterator::with_line_number(word_diff_hunks.iter(), line_number);
1130 for diff_line in diff_line_iter.by_ref() {
1131 show_color_words_line_number(
1132 formatter,
1133 Diff::new(
1134 diff_line
1135 .has_left_content()
1136 .then_some(diff_line.line_number.left),
1137 diff_line
1138 .has_right_content()
1139 .then_some(diff_line.line_number.right),
1140 ),
1141 labels,
1142 )?;
1143 show_color_words_inline_hunks(formatter, &diff_line.hunks, labels)?;
1144 }
1145 line_number = diff_line_iter.next_line_number();
1146 } else {
1147 let lines = unzip_diff_hunks_to_lines(&word_diff_hunks);
1148 for tokens in &lines.before {
1149 show_color_words_line_number(
1150 formatter,
1151 Diff::new(Some(line_number.left), None),
1152 labels,
1153 )?;
1154 show_color_words_single_sided_line(formatter, tokens, labels.before)?;
1155 line_number.left += 1;
1156 }
1157 for tokens in &lines.after {
1158 show_color_words_line_number(
1159 formatter,
1160 Diff::new(None, Some(line_number.right)),
1161 labels,
1162 )?;
1163 show_color_words_single_sided_line(formatter, tokens, labels.after)?;
1164 line_number.right += 1;
1165 }
1166 }
1167 Ok(line_number)
1168}
1169
1170fn show_color_words_line_number(
1171 formatter: &mut dyn Formatter,
1172 line_numbers: Diff<Option<u32>>,
1173 labels: Diff<&str>,
1174) -> io::Result<()> {
1175 if let Some(line_number) = line_numbers.before {
1176 write!(
1177 formatter.labeled(labels.before).labeled("line_number"),
1178 "{line_number:>4}"
1179 )?;
1180 write!(formatter, " ")?;
1181 } else {
1182 write!(formatter, " ")?;
1183 }
1184 if let Some(line_number) = line_numbers.after {
1185 write!(
1186 formatter.labeled(labels.after).labeled("line_number"),
1187 "{line_number:>4}"
1188 )?;
1189 write!(formatter, ": ")?;
1190 } else {
1191 write!(formatter, " : ")?;
1192 }
1193 Ok(())
1194}
1195
1196fn show_color_words_inline_hunks(
1198 formatter: &mut dyn Formatter,
1199 line_hunks: &[(DiffLineHunkSide, &BStr)],
1200 labels: Diff<&str>,
1201) -> io::Result<()> {
1202 for (side, data) in line_hunks {
1203 let label = match side {
1204 DiffLineHunkSide::Both => None,
1205 DiffLineHunkSide::Left => Some(labels.before),
1206 DiffLineHunkSide::Right => Some(labels.after),
1207 };
1208 if let Some(label) = label {
1209 formatter.labeled(label).labeled("token").write_all(data)?;
1210 } else {
1211 formatter.write_all(data)?;
1212 }
1213 }
1214 let (_, data) = line_hunks.last().expect("diff line must not be empty");
1215 if !data.ends_with(b"\n") {
1216 writeln!(formatter)?;
1217 }
1218 Ok(())
1219}
1220
1221fn show_color_words_single_sided_line(
1223 formatter: &mut dyn Formatter,
1224 tokens: &[(DiffTokenType, &[u8])],
1225 label: &str,
1226) -> io::Result<()> {
1227 show_diff_line_tokens(*formatter.labeled(label), tokens)?;
1228 let (_, data) = tokens.last().expect("diff line must not be empty");
1229 if !data.ends_with(b"\n") {
1230 writeln!(formatter)?;
1231 }
1232 Ok(())
1233}
1234
1235fn count_diff_alternation(diff_hunks: &[DiffHunk]) -> usize {
1248 diff_hunks
1249 .iter()
1250 .filter_map(|hunk| match hunk.kind {
1251 DiffHunkKind::Matching => None,
1252 DiffHunkKind::Different => Some(&hunk.contents),
1253 })
1254 .flat_map(|contents| contents.iter().positions(|content| !content.is_empty()))
1256 .dedup()
1258 .count()
1259}
1260
1261fn split_diff_hunks_by_matching_newline<'a, 'b>(
1263 diff_hunks: &'a [DiffHunk<'b>],
1264) -> impl Iterator<Item = &'a [DiffHunk<'b>]> {
1265 diff_hunks.split_inclusive(|hunk| match hunk.kind {
1266 DiffHunkKind::Matching => hunk.contents.iter().all(|content| content.contains(&b'\n')),
1267 DiffHunkKind::Different => false,
1268 })
1269}
1270
1271async fn diff_content(
1272 path: &RepoPath,
1273 value: MaterializedTreeValue,
1274 materialize_options: &ConflictMaterializeOptions,
1275) -> BackendResult<FileContent<BString>> {
1276 diff_content_with(
1277 path,
1278 value,
1279 |content| content,
1280 |contents, labels| {
1281 materialize_merge_result_to_bytes(&contents, &labels, materialize_options)
1282 },
1283 )
1284 .await
1285}
1286
1287#[derive(PartialEq, Eq, Debug)]
1288struct DiffContentAsMerge {
1289 file_content: Merge<BString>,
1290 conflict_labels: ConflictLabels,
1291}
1292
1293impl DiffContentAsMerge {
1294 pub fn is_empty(&self) -> bool {
1295 self.file_content
1296 .as_resolved()
1297 .is_some_and(|c| c.is_empty())
1298 }
1299}
1300
1301async fn diff_content_as_merge(
1302 path: &RepoPath,
1303 value: MaterializedTreeValue,
1304) -> BackendResult<FileContent<DiffContentAsMerge>> {
1305 diff_content_with(
1306 path,
1307 value,
1308 |contents| DiffContentAsMerge {
1309 file_content: Merge::resolved(contents),
1310 conflict_labels: ConflictLabels::unlabeled(),
1311 },
1312 |contents, labels| DiffContentAsMerge {
1313 file_content: contents,
1314 conflict_labels: labels,
1315 },
1316 )
1317 .await
1318}
1319
1320async fn diff_content_with<T>(
1321 path: &RepoPath,
1322 value: MaterializedTreeValue,
1323 map_resolved: impl FnOnce(BString) -> T,
1324 map_conflict: impl FnOnce(Merge<BString>, ConflictLabels) -> T,
1325) -> BackendResult<FileContent<T>> {
1326 match value {
1327 MaterializedTreeValue::Absent => Ok(FileContent {
1328 is_binary: false,
1329 contents: map_resolved(BString::default()),
1330 }),
1331 MaterializedTreeValue::AccessDenied(err) => Ok(FileContent {
1332 is_binary: false,
1333 contents: map_resolved(format!("Access denied: {err}").into()),
1334 }),
1335 MaterializedTreeValue::File(mut file) => {
1336 file_content_for_diff(path, &mut file, map_resolved).await
1337 }
1338 MaterializedTreeValue::Symlink { id: _, target } => Ok(FileContent {
1339 is_binary: false,
1341 contents: map_resolved(target.into()),
1342 }),
1343 MaterializedTreeValue::GitSubmodule(id) => Ok(FileContent {
1344 is_binary: false,
1345 contents: map_resolved(format!("Git submodule checked out at {id}").into()),
1346 }),
1347 MaterializedTreeValue::FileConflict(file) => Ok(FileContent {
1349 is_binary: false,
1350 contents: map_conflict(file.contents, file.labels),
1351 }),
1352 MaterializedTreeValue::OtherConflict { id, labels } => Ok(FileContent {
1353 is_binary: false,
1354 contents: map_resolved(id.describe(&labels).into()),
1355 }),
1356 MaterializedTreeValue::Tree(id) => {
1357 panic!("Unexpected tree with id {id:?} in diff at path {path:?}");
1358 }
1359 }
1360}
1361
1362fn basic_diff_file_type(value: &MaterializedTreeValue) -> &'static str {
1363 match value {
1364 MaterializedTreeValue::Absent => {
1365 panic!("absent path in diff");
1366 }
1367 MaterializedTreeValue::AccessDenied(_) => "access denied",
1368 MaterializedTreeValue::File(file) => {
1369 if file.executable {
1370 "executable file"
1371 } else {
1372 "regular file"
1373 }
1374 }
1375 MaterializedTreeValue::Symlink { .. } => "symlink",
1376 MaterializedTreeValue::Tree(_) => "tree",
1377 MaterializedTreeValue::GitSubmodule(_) => "Git submodule",
1378 MaterializedTreeValue::FileConflict(_) | MaterializedTreeValue::OtherConflict { .. } => {
1379 "conflict"
1380 }
1381 }
1382}
1383
1384pub async fn show_color_words_diff(
1385 formatter: &mut dyn Formatter,
1386 store: &Store,
1387 tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
1388 conflict_labels: Diff<&ConflictLabels>,
1389 path_converter: &RepoPathUiConverter,
1390 options: &ColorWordsDiffOptions,
1391 marker_style: ConflictMarkerStyle,
1392) -> Result<(), DiffRenderError> {
1393 let materialize_options = ConflictMaterializeOptions {
1394 marker_style,
1395 marker_len: None,
1396 merge: store.merge_options().clone(),
1397 };
1398 let empty_content = || Merge::resolved(BString::default());
1399 let mut diff_stream = materialized_diff_stream(store, tree_diff, conflict_labels);
1400 while let Some(MaterializedTreeDiffEntry { path, values }) = diff_stream.next().await {
1401 let left_path = path.source();
1402 let right_path = path.target();
1403 let left_ui_path = path_converter.format_file_path(left_path);
1404 let right_ui_path = path_converter.format_file_path(right_path);
1405 let Diff {
1406 before: left_value,
1407 after: right_value,
1408 } = values?;
1409
1410 match (&left_value, &right_value) {
1411 (MaterializedTreeValue::AccessDenied(source), _) => {
1412 write!(
1413 formatter.labeled("access-denied"),
1414 "Access denied to {left_ui_path}:"
1415 )?;
1416 writeln!(formatter, " {source}")?;
1417 continue;
1418 }
1419 (_, MaterializedTreeValue::AccessDenied(source)) => {
1420 write!(
1421 formatter.labeled("access-denied"),
1422 "Access denied to {right_ui_path}:"
1423 )?;
1424 writeln!(formatter, " {source}")?;
1425 continue;
1426 }
1427 _ => {}
1428 }
1429 if left_value.is_absent() {
1430 let description = basic_diff_file_type(&right_value);
1431 writeln!(
1432 formatter.labeled("header"),
1433 "Added {description} {right_ui_path}:"
1434 )?;
1435 let right_content = diff_content_as_merge(right_path, right_value).await?;
1436 if right_content.contents.is_empty() {
1437 writeln!(formatter.labeled("empty"), " (empty)")?;
1438 } else if right_content.is_binary {
1439 writeln!(formatter.labeled("binary"), " (binary)")?;
1440 } else {
1441 show_color_words_diff_hunks(
1442 formatter,
1443 Diff::new(&empty_content(), &right_content.contents.file_content),
1444 Diff::new(
1445 &ConflictLabels::unlabeled(),
1446 &right_content.contents.conflict_labels,
1447 ),
1448 options,
1449 &materialize_options,
1450 )?;
1451 }
1452 } else if right_value.is_present() {
1453 let description = match (&left_value, &right_value) {
1454 (MaterializedTreeValue::File(left), MaterializedTreeValue::File(right)) => {
1455 if left.executable && right.executable {
1456 "Modified executable file".to_string()
1457 } else if left.executable {
1458 "Executable file became non-executable at".to_string()
1459 } else if right.executable {
1460 "Non-executable file became executable at".to_string()
1461 } else {
1462 "Modified regular file".to_string()
1463 }
1464 }
1465 (
1466 MaterializedTreeValue::FileConflict(_)
1467 | MaterializedTreeValue::OtherConflict { .. },
1468 MaterializedTreeValue::FileConflict(_)
1469 | MaterializedTreeValue::OtherConflict { .. },
1470 ) => "Modified conflict in".to_string(),
1471 (
1472 MaterializedTreeValue::FileConflict(_)
1473 | MaterializedTreeValue::OtherConflict { .. },
1474 _,
1475 ) => "Resolved conflict in".to_string(),
1476 (
1477 _,
1478 MaterializedTreeValue::FileConflict(_)
1479 | MaterializedTreeValue::OtherConflict { .. },
1480 ) => "Created conflict in".to_string(),
1481 (MaterializedTreeValue::Symlink { .. }, MaterializedTreeValue::Symlink { .. }) => {
1482 "Symlink target changed at".to_string()
1483 }
1484 (_, _) => {
1485 let left_type = basic_diff_file_type(&left_value);
1486 let right_type = basic_diff_file_type(&right_value);
1487 let (first, rest) = left_type.split_at(1);
1488 format!(
1489 "{}{} became {} at",
1490 first.to_ascii_uppercase(),
1491 rest,
1492 right_type
1493 )
1494 }
1495 };
1496 let left_content = diff_content_as_merge(left_path, left_value).await?;
1497 let right_content = diff_content_as_merge(right_path, right_value).await?;
1498 if left_path == right_path {
1499 writeln!(
1500 formatter.labeled("header"),
1501 "{description} {right_ui_path}:"
1502 )?;
1503 } else {
1504 writeln!(
1505 formatter.labeled("header"),
1506 "{description} {right_ui_path} ({left_ui_path} => {right_ui_path}):"
1507 )?;
1508 }
1509 if left_content.is_binary || right_content.is_binary {
1510 writeln!(formatter.labeled("binary"), " (binary)")?;
1511 } else if left_content.contents != right_content.contents {
1512 show_color_words_diff_hunks(
1513 formatter,
1514 Diff::new(
1515 &left_content.contents.file_content,
1516 &right_content.contents.file_content,
1517 ),
1518 Diff::new(
1519 &left_content.contents.conflict_labels,
1520 &right_content.contents.conflict_labels,
1521 ),
1522 options,
1523 &materialize_options,
1524 )?;
1525 }
1526 } else {
1527 let description = basic_diff_file_type(&left_value);
1528 writeln!(
1529 formatter.labeled("header"),
1530 "Removed {description} {right_ui_path}:"
1531 )?;
1532 let left_content = diff_content_as_merge(left_path, left_value).await?;
1533 if left_content.contents.is_empty() {
1534 writeln!(formatter.labeled("empty"), " (empty)")?;
1535 } else if left_content.is_binary {
1536 writeln!(formatter.labeled("binary"), " (binary)")?;
1537 } else {
1538 show_color_words_diff_hunks(
1539 formatter,
1540 Diff::new(&left_content.contents.file_content, &empty_content()),
1541 Diff::new(
1542 &left_content.contents.conflict_labels,
1543 &ConflictLabels::unlabeled(),
1544 ),
1545 options,
1546 &materialize_options,
1547 )?;
1548 }
1549 }
1550 }
1551 Ok(())
1552}
1553
1554#[expect(clippy::too_many_arguments)]
1555pub async fn show_file_by_file_diff(
1556 ui: &Ui,
1557 formatter: &mut dyn Formatter,
1558 store: &Store,
1559 tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
1560 conflict_labels: Diff<&ConflictLabels>,
1561 path_converter: &RepoPathUiConverter,
1562 tool: &ExternalMergeTool,
1563 marker_style: ConflictMarkerStyle,
1564 width: usize,
1565) -> Result<(), DiffRenderError> {
1566 let materialize_options = ConflictMaterializeOptions {
1567 marker_style,
1568 marker_len: None,
1569 merge: store.merge_options().clone(),
1570 };
1571 let create_file = async |path: &RepoPath,
1572 wc_dir: &Path,
1573 value: MaterializedTreeValue|
1574 -> Result<PathBuf, DiffRenderError> {
1575 let fs_path = path.to_fs_path(wc_dir)?;
1576 std::fs::create_dir_all(fs_path.parent().unwrap())?;
1577 let content = diff_content(path, value, &materialize_options).await?;
1578 std::fs::write(&fs_path, content.contents)?;
1579 Ok(fs_path)
1580 };
1581
1582 let temp_dir = new_utf8_temp_dir("jj-diff-")?;
1583 let left_wc_dir = temp_dir.path().join("left");
1584 let right_wc_dir = temp_dir.path().join("right");
1585 let mut diff_stream = materialized_diff_stream(store, tree_diff, conflict_labels);
1586 while let Some(MaterializedTreeDiffEntry { path, values }) = diff_stream.next().await {
1587 let Diff {
1588 before: left_value,
1589 after: right_value,
1590 } = values?;
1591 let left_path = path.source();
1592 let right_path = path.target();
1593 let left_ui_path = path_converter.format_file_path(left_path);
1594 let right_ui_path = path_converter.format_file_path(right_path);
1595
1596 match (&left_value, &right_value) {
1597 (_, MaterializedTreeValue::AccessDenied(source)) => {
1598 write!(
1599 formatter.labeled("access-denied"),
1600 "Access denied to {right_ui_path}:"
1601 )?;
1602 writeln!(formatter, " {source}")?;
1603 continue;
1604 }
1605 (MaterializedTreeValue::AccessDenied(source), _) => {
1606 write!(
1607 formatter.labeled("access-denied"),
1608 "Access denied to {left_ui_path}:"
1609 )?;
1610 writeln!(formatter, " {source}")?;
1611 continue;
1612 }
1613 _ => {}
1614 }
1615 let left_path = create_file(left_path, &left_wc_dir, left_value).await?;
1616 let right_path = create_file(right_path, &right_wc_dir, right_value).await?;
1617 let patterns = &maplit::hashmap! {
1618 "left" => left_path
1619 .strip_prefix(temp_dir.path())
1620 .expect("path should be relative to temp_dir")
1621 .to_str()
1622 .expect("temp_dir should be valid utf-8")
1623 .to_owned(),
1624 "right" => right_path
1625 .strip_prefix(temp_dir.path())
1626 .expect("path should be relative to temp_dir")
1627 .to_str()
1628 .expect("temp_dir should be valid utf-8")
1629 .to_owned(),
1630 "width" => width.to_string(),
1631 };
1632
1633 let mut writer = formatter.raw()?;
1634 invoke_external_diff(ui, writer.as_mut(), tool, temp_dir.path(), patterns)
1635 .map_err(DiffRenderError::DiffGenerate)?;
1636 }
1637 Ok::<(), DiffRenderError>(())
1638}
1639
1640#[derive(Clone, Debug, Eq, PartialEq)]
1641pub struct UnifiedDiffOptions {
1642 pub context: usize,
1644 pub show_path_prefix: bool,
1646 pub line_diff: LineDiffOptions,
1648}
1649
1650impl UnifiedDiffOptions {
1651 pub fn from_settings(settings: &UserSettings) -> Result<Self, ConfigGetError> {
1652 Ok(Self {
1653 context: settings.get("diff.git.context")?,
1654 show_path_prefix: settings.get("diff.git.show-path-prefix")?,
1655 line_diff: LineDiffOptions::default(),
1656 })
1657 }
1658
1659 fn merge_args(&mut self, args: &DiffFormatArgs) {
1660 if let Some(context) = args.context {
1661 self.context = context;
1662 }
1663 self.line_diff.merge_args(args);
1664 }
1665}
1666
1667fn show_unified_diff_hunks(
1668 formatter: &mut dyn Formatter,
1669 contents: Diff<&BStr>,
1670 options: &UnifiedDiffOptions,
1671) -> io::Result<()> {
1672 fn to_line_number(range: Range<usize>) -> usize {
1680 if range.is_empty() {
1681 range.start
1682 } else {
1683 range.start + 1
1684 }
1685 }
1686
1687 for hunk in unified_diff_hunks(contents, options.context, options.line_diff.compare_mode) {
1688 writeln!(
1689 formatter.labeled("hunk_header"),
1690 "@@ -{},{} +{},{} @@",
1691 to_line_number(hunk.left_line_range.clone()),
1692 hunk.left_line_range.len(),
1693 to_line_number(hunk.right_line_range.clone()),
1694 hunk.right_line_range.len()
1695 )?;
1696 for (line_type, tokens) in &hunk.lines {
1697 let (label, sigil) = match line_type {
1698 DiffLineType::Context => ("context", " "),
1699 DiffLineType::Removed => ("removed", "-"),
1700 DiffLineType::Added => ("added", "+"),
1701 };
1702 write!(formatter.labeled(label), "{sigil}")?;
1703 show_diff_line_tokens(*formatter.labeled(label), tokens)?;
1704 let (_, content) = tokens.last().expect("hunk line must not be empty");
1705 if !content.ends_with(b"\n") {
1706 write!(formatter, "\n\\ No newline at end of file\n")?;
1707 }
1708 }
1709 }
1710 Ok(())
1711}
1712
1713fn show_diff_line_tokens(
1714 formatter: &mut dyn Formatter,
1715 tokens: &[(DiffTokenType, &[u8])],
1716) -> io::Result<()> {
1717 for (token_type, content) in tokens {
1718 match token_type {
1719 DiffTokenType::Matching => formatter.write_all(content)?,
1720 DiffTokenType::Different => formatter.labeled("token").write_all(content)?,
1721 }
1722 }
1723 Ok(())
1724}
1725
1726pub async fn show_git_diff(
1727 formatter: &mut dyn Formatter,
1728 store: &Store,
1729 tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
1730 conflict_labels: Diff<&ConflictLabels>,
1731 options: &UnifiedDiffOptions,
1732 marker_style: ConflictMarkerStyle,
1733) -> Result<(), DiffRenderError> {
1734 let materialize_options = ConflictMaterializeOptions {
1735 marker_style,
1736 marker_len: None,
1737 merge: store.merge_options().clone(),
1738 };
1739 let mut diff_stream = materialized_diff_stream(store, tree_diff, conflict_labels);
1740 while let Some(MaterializedTreeDiffEntry { path, values }) = diff_stream.next().await {
1741 let left_path = path.source();
1742 let right_path = path.target();
1743 let left_prefix = if options.show_path_prefix { "a/" } else { "" };
1744 let right_prefix = if options.show_path_prefix { "b/" } else { "" };
1745 let left_path_string = left_path.as_internal_file_string();
1746 let right_path_string = right_path.as_internal_file_string();
1747 let values = values?;
1748
1749 let left_part = git_diff_part(left_path, values.before, &materialize_options).await?;
1750 let right_part = git_diff_part(right_path, values.after, &materialize_options).await?;
1751
1752 {
1753 let mut formatter = formatter.labeled("file_header");
1754 writeln!(
1755 formatter,
1756 "diff --git {left_prefix}{left_path_string} {right_prefix}{right_path_string}"
1757 )?;
1758 let left_hash = &left_part.hash;
1759 let right_hash = &right_part.hash;
1760 match (left_part.mode, right_part.mode) {
1761 (None, Some(right_mode)) => {
1762 writeln!(formatter, "new file mode {right_mode}")?;
1763 writeln!(formatter, "index {left_hash}..{right_hash}")?;
1764 }
1765 (Some(left_mode), None) => {
1766 writeln!(formatter, "deleted file mode {left_mode}")?;
1767 writeln!(formatter, "index {left_hash}..{right_hash}")?;
1768 }
1769 (Some(left_mode), Some(right_mode)) => {
1770 if let Some(op) = path.copy_operation() {
1771 let operation = match op {
1772 CopyOperation::Copy => "copy",
1773 CopyOperation::Rename => "rename",
1774 };
1775 writeln!(formatter, "{operation} from {left_path_string}")?;
1777 writeln!(formatter, "{operation} to {right_path_string}")?;
1778 }
1779 if left_mode != right_mode {
1780 writeln!(formatter, "old mode {left_mode}")?;
1781 writeln!(formatter, "new mode {right_mode}")?;
1782 if left_hash != right_hash {
1783 writeln!(formatter, "index {left_hash}..{right_hash}")?;
1784 }
1785 } else if left_hash != right_hash {
1786 writeln!(formatter, "index {left_hash}..{right_hash} {left_mode}")?;
1787 }
1788 }
1789 (None, None) => panic!("either left or right part should be present"),
1790 }
1791 }
1792
1793 if left_part.content.contents == right_part.content.contents {
1794 continue; }
1796
1797 let left_path = match left_part.mode {
1798 Some(_) => format!("{left_prefix}{left_path_string}"),
1799 None => "/dev/null".to_owned(),
1800 };
1801 let right_path = match right_part.mode {
1802 Some(_) => format!("{right_prefix}{right_path_string}"),
1803 None => "/dev/null".to_owned(),
1804 };
1805 if left_part.content.is_binary || right_part.content.is_binary {
1806 writeln!(
1808 formatter,
1809 "Binary files {left_path} and {right_path} differ"
1810 )?;
1811 } else {
1812 writeln!(formatter.labeled("file_header"), "--- {left_path}")?;
1813 writeln!(formatter.labeled("file_header"), "+++ {right_path}")?;
1814 show_unified_diff_hunks(
1815 formatter,
1816 Diff::new(&left_part.content.contents, &right_part.content.contents).map(BStr::new),
1817 options,
1818 )?;
1819 }
1820 }
1821 Ok(())
1822}
1823
1824fn show_git_diff_texts<T: AsRef<[u8]>>(
1826 formatter: &mut dyn Formatter,
1827 paths: Diff<&str>,
1828 contents: Diff<&Merge<T>>,
1829 options: &UnifiedDiffOptions,
1830 materialize_options: &ConflictMaterializeOptions,
1831) -> io::Result<()> {
1832 let Diff {
1833 before: left_path,
1834 after: right_path,
1835 } = paths;
1836 {
1837 let mut formatter = formatter.labeled("file_header");
1838 writeln!(formatter, "diff --git a/{left_path} b/{right_path}")?;
1839 writeln!(formatter, "--- {left_path}")?;
1840 writeln!(formatter, "+++ {right_path}")?;
1841 }
1842 let contents = contents.map(|content| match content.as_resolved() {
1843 Some(text) => Cow::Borrowed(BStr::new(text)),
1844 None => Cow::Owned(materialize_merge_result_to_bytes(
1845 content,
1846 &ConflictLabels::unlabeled(),
1847 materialize_options,
1848 )),
1849 });
1850 show_unified_diff_hunks(formatter, contents.as_ref().map(Cow::as_ref), options)
1851}
1852
1853#[instrument(skip_all)]
1854pub async fn show_diff_summary(
1855 formatter: &mut dyn Formatter,
1856 mut tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
1857 path_converter: &RepoPathUiConverter,
1858) -> Result<(), DiffRenderError> {
1859 while let Some(CopiesTreeDiffEntry { path, values }) = tree_diff.next().await {
1860 let values = values?;
1861 let status = diff_status(&path, &values);
1862 let (label, sigil) = (status.label(), status.char());
1863 let ui_path = match path.to_diff() {
1864 Some(paths) => path_converter.format_copied_path(paths),
1865 None => path_converter.format_file_path(path.target()),
1866 };
1867 writeln!(formatter.labeled(label), "{sigil} {ui_path}")?;
1868 }
1869 Ok(())
1870}
1871
1872fn diff_status_inner(
1873 path: &CopiesTreeDiffEntryPath,
1874 is_present_before: bool,
1875 is_present_after: bool,
1876) -> DiffEntryStatus {
1877 if let Some(op) = path.copy_operation() {
1878 match op {
1879 CopyOperation::Copy => DiffEntryStatus::Copied,
1880 CopyOperation::Rename => DiffEntryStatus::Renamed,
1881 }
1882 } else {
1883 match (is_present_before, is_present_after) {
1884 (true, true) => DiffEntryStatus::Modified,
1885 (false, true) => DiffEntryStatus::Added,
1886 (true, false) => DiffEntryStatus::Removed,
1887 (false, false) => panic!("values pair must differ"),
1888 }
1889 }
1890}
1891
1892pub fn diff_status(
1893 path: &CopiesTreeDiffEntryPath,
1894 values: &Diff<MergedTreeValue>,
1895) -> DiffEntryStatus {
1896 diff_status_inner(path, values.before.is_present(), values.after.is_present())
1897}
1898
1899#[derive(Clone, Debug, Default, Eq, PartialEq)]
1900pub struct DiffStatOptions {
1901 pub line_diff: LineDiffOptions,
1903 pub max_bar_width: Option<usize>,
1906}
1907
1908impl DiffStatOptions {
1909 pub fn from_settings(settings: &UserSettings) -> Result<Self, ConfigGetError> {
1910 Ok(Self {
1911 line_diff: LineDiffOptions::default(),
1912 max_bar_width: settings.get("diff.stat.max-bar-width").optional()?,
1913 })
1914 }
1915
1916 fn merge_args(&mut self, args: &DiffFormatArgs) {
1917 self.line_diff.merge_args(args);
1918 }
1919}
1920
1921#[derive(Clone, Debug)]
1922pub struct DiffStats {
1923 entries: Vec<DiffStatEntry>,
1924}
1925
1926impl DiffStats {
1927 pub async fn calculate(
1929 store: &Store,
1930 tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
1931 options: &DiffStatOptions,
1932 marker_style: ConflictMarkerStyle,
1933 ) -> BackendResult<Self> {
1934 let materialize_options = ConflictMaterializeOptions {
1935 marker_style,
1936 marker_len: None,
1937 merge: store.merge_options().clone(),
1938 };
1939 let conflict_labels = ConflictLabels::unlabeled();
1940 let entries = materialized_diff_stream(
1941 store,
1942 tree_diff,
1943 Diff::new(&conflict_labels, &conflict_labels),
1944 )
1945 .then(async |MaterializedTreeDiffEntry { path, values }| {
1946 let values = values?;
1947 let status =
1948 diff_status_inner(&path, values.before.is_present(), values.after.is_present());
1949 let left_content =
1950 diff_content(path.source(), values.before, &materialize_options).await?;
1951 let right_content =
1952 diff_content(path.target(), values.after, &materialize_options).await?;
1953 let stat = get_diff_stat_entry(
1954 path,
1955 status,
1956 Diff::new(&left_content, &right_content),
1957 options,
1958 );
1959 BackendResult::Ok(stat)
1960 })
1961 .try_collect()
1962 .await?;
1963 Ok(Self { entries })
1964 }
1965
1966 pub fn entries(&self) -> &[DiffStatEntry] {
1968 &self.entries
1969 }
1970
1971 pub fn count_total_added(&self) -> usize {
1973 self.entries
1974 .iter()
1975 .filter_map(|stat| stat.added_removed.map(|(added, _)| added))
1976 .sum()
1977 }
1978
1979 pub fn count_total_removed(&self) -> usize {
1981 self.entries
1982 .iter()
1983 .filter_map(|stat| stat.added_removed.map(|(_, removed)| removed))
1984 .sum()
1985 }
1986}
1987
1988#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1989pub enum DiffEntryStatus {
1990 Added,
1991 Removed,
1992 Modified,
1993 Copied,
1994 Renamed,
1995}
1996
1997impl DiffEntryStatus {
1998 pub fn label(&self) -> &'static str {
1999 match self {
2000 Self::Added => "added",
2001 Self::Removed => "removed",
2002 Self::Modified => "modified",
2003 Self::Copied => "copied",
2004 Self::Renamed => "renamed",
2005 }
2006 }
2007
2008 pub fn char(&self) -> char {
2009 match self {
2010 Self::Added => 'A',
2011 Self::Removed => 'D',
2012 Self::Modified => 'M',
2013 Self::Copied => 'C',
2014 Self::Renamed => 'R',
2015 }
2016 }
2017}
2018
2019#[derive(Clone, Debug)]
2020pub struct DiffStatEntry {
2021 pub path: CopiesTreeDiffEntryPath,
2022 pub added_removed: Option<(usize, usize)>,
2024 pub bytes_delta: isize,
2026 pub status: DiffEntryStatus,
2027}
2028
2029fn get_diff_stat_entry(
2030 path: CopiesTreeDiffEntryPath,
2031 status: DiffEntryStatus,
2032 contents: Diff<&FileContent<BString>>,
2033 options: &DiffStatOptions,
2034) -> DiffStatEntry {
2035 let added_removed = if contents.before.is_binary || contents.after.is_binary {
2036 None
2037 } else {
2038 let diff = diff_by_line(
2039 contents.map(|content| &content.contents).into_array(),
2040 &options.line_diff.compare_mode,
2041 );
2042 let mut added = 0;
2043 let mut removed = 0;
2044 for hunk in diff.hunks() {
2045 match hunk.kind {
2046 DiffHunkKind::Matching => {}
2047 DiffHunkKind::Different => {
2048 let [left, right] = hunk.contents[..].try_into().unwrap();
2049 removed += left.split_inclusive(|b| *b == b'\n').count();
2050 added += right.split_inclusive(|b| *b == b'\n').count();
2051 }
2052 }
2053 }
2054 Some((added, removed))
2055 };
2056
2057 DiffStatEntry {
2058 path,
2059 added_removed,
2060 bytes_delta: contents.after.contents.len() as isize
2061 - contents.before.contents.len() as isize,
2062 status,
2063 }
2064}
2065
2066pub fn show_diff_stats(
2067 formatter: &mut dyn Formatter,
2068 stats: &DiffStats,
2069 path_converter: &RepoPathUiConverter,
2070 total_display_width: usize,
2071 options: &DiffStatOptions,
2072) -> io::Result<()> {
2073 let ui_paths = stats
2074 .entries()
2075 .iter()
2076 .map(|stat| match stat.path.to_diff() {
2077 Some(paths) => path_converter.format_copied_path(paths),
2078 None => path_converter.format_file_path(stat.path.target()),
2079 })
2080 .collect_vec();
2081
2082 let mut max_path_width = ui_paths.iter().map(|s| s.width()).max().unwrap_or(0);
2093
2094 let available_width = max(total_display_width.saturating_sub(" | ".len()), 8);
2097
2098 let max_diffs = stats
2101 .entries()
2102 .iter()
2103 .filter_map(|stat| {
2104 let (added, removed) = stat.added_removed?;
2105 Some(added + removed)
2106 })
2107 .max();
2108 let diff_number_width = max_diffs.map_or(0, |n| n.to_string().len());
2109 if max_diffs.is_some() {
2110 let width = diff_number_width + " ".len();
2111 max_path_width =
2113 max_path_width.min((0.7 * available_width.saturating_sub(width) as f64) as usize);
2114 }
2115
2116 let max_bytes = stats
2119 .entries
2120 .iter()
2121 .filter(|stat| stat.added_removed.is_none())
2122 .map(|stat| stat.bytes_delta.abs())
2123 .max();
2124 if let Some(max) = max_bytes {
2125 let width = if max > 0 {
2126 format!("(binary) {max:+} bytes").len()
2127 } else {
2128 "(binary)".len()
2129 };
2130 max_path_width = max_path_width.min(available_width.saturating_sub(width));
2131 }
2132
2133 let mut max_bar_width =
2137 available_width.saturating_sub(max_path_width + diff_number_width + " ".len());
2138 if let Some(bar_width) = options.max_bar_width {
2139 max_bar_width = min(max_bar_width, bar_width);
2140 }
2141
2142 let factor = match max_diffs {
2143 Some(max) if max > max_bar_width => max_bar_width as f64 / max as f64,
2144 _ => 1.0,
2145 };
2146
2147 for (stat, ui_path) in iter::zip(stats.entries(), &ui_paths) {
2148 let (path, path_width) = text_util::elide_start(ui_path, "...", max_path_width);
2150 let path_pad_width = max_path_width - path_width;
2151 write!(
2152 formatter,
2153 "{path}{:path_pad_width$} | ",
2154 "", )?;
2156 if let Some((added, removed)) = stat.added_removed {
2157 let bar_length = ((added + removed) as f64 * factor) as usize;
2158 let bar_length = bar_length.max(usize::from(added > 0) + usize::from(removed > 0));
2165 let (bar_added, bar_removed) = if added < removed {
2166 let len = (added as f64 * factor).ceil() as usize;
2167 (len, bar_length - len)
2168 } else {
2169 let len = (removed as f64 * factor).ceil() as usize;
2170 (bar_length - len, len)
2171 };
2172 write!(
2173 formatter,
2174 "{:>diff_number_width$}{}",
2175 added + removed,
2176 if bar_added + bar_removed > 0 { " " } else { "" },
2177 )?;
2178 write!(formatter.labeled("added"), "{}", "+".repeat(bar_added))?;
2179 writeln!(formatter.labeled("removed"), "{}", "-".repeat(bar_removed))?;
2180 } else {
2181 write!(formatter.labeled("binary"), "(binary)")?;
2182 if stat.bytes_delta != 0 {
2183 let label = if stat.bytes_delta < 0 {
2184 "removed"
2185 } else {
2186 "added"
2187 };
2188 write!(formatter.labeled(label), " {:+}", stat.bytes_delta)?;
2189 write!(formatter, " bytes")?;
2190 }
2191 writeln!(formatter)?;
2192 }
2193 }
2194
2195 let total_added = stats.count_total_added();
2196 let total_removed = stats.count_total_removed();
2197 let total_files = stats.entries().len();
2198 writeln!(
2199 formatter.labeled("stat-summary"),
2200 "{} file{} changed, {} insertion{}(+), {} deletion{}(-)",
2201 total_files,
2202 if total_files == 1 { "" } else { "s" },
2203 total_added,
2204 if total_added == 1 { "" } else { "s" },
2205 total_removed,
2206 if total_removed == 1 { "" } else { "s" },
2207 )?;
2208 Ok(())
2209}
2210
2211pub async fn show_types(
2212 formatter: &mut dyn Formatter,
2213 mut tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
2214 path_converter: &RepoPathUiConverter,
2215) -> Result<(), DiffRenderError> {
2216 while let Some(CopiesTreeDiffEntry { path, values }) = tree_diff.next().await {
2217 let values = values?;
2218 let ui_path = match path.to_diff() {
2219 Some(paths) => path_converter.format_copied_path(paths),
2220 None => path_converter.format_file_path(path.target()),
2221 };
2222 writeln!(
2223 formatter.labeled("modified"),
2224 "{before}{after} {ui_path}",
2225 before = diff_summary_char(&values.before),
2226 after = diff_summary_char(&values.after)
2227 )?;
2228 }
2229 Ok(())
2230}
2231
2232fn diff_summary_char(value: &MergedTreeValue) -> char {
2233 match value.as_resolved() {
2234 Some(None) => '-',
2235 Some(Some(TreeValue::File { .. })) => 'F',
2236 Some(Some(TreeValue::Symlink(_))) => 'L',
2237 Some(Some(TreeValue::GitSubmodule(_))) => 'G',
2238 None => 'C',
2239 Some(Some(TreeValue::Tree(_))) => {
2240 panic!("Unexpected {value:?} in diff")
2241 }
2242 }
2243}
2244
2245pub async fn show_names(
2246 formatter: &mut dyn Formatter,
2247 mut tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
2248 path_converter: &RepoPathUiConverter,
2249) -> io::Result<()> {
2250 while let Some(CopiesTreeDiffEntry { path, .. }) = tree_diff.next().await {
2251 writeln!(
2252 formatter,
2253 "{}",
2254 path_converter.format_file_path(path.target())
2255 )?;
2256 }
2257 Ok(())
2258}
2259
2260pub async fn show_templated(
2261 formatter: &mut dyn Formatter,
2262 mut tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,
2263 template: &TemplateRenderer<'_, commit_templater::TreeDiffEntry>,
2264) -> Result<(), DiffRenderError> {
2265 while let Some(entry) = tree_diff.next().await {
2266 let entry = commit_templater::TreeDiffEntry::from_backend_entry_with_copies(entry)?;
2267 template.format(&entry, formatter)?;
2268 }
2269 Ok(())
2270}