1use std::ops::Range;
9
10use bstr::{BStr, ByteSlice};
11use gix_object::tree::{EntryKind, EntryMode};
12
13use crate::{
14 blob::{platform::prepare_diff::Operation, DiffLineStats, ResourceKind},
15 rewrites::{tracker::visit::SourceKind, CopySource, Outcome, Tracker},
16 tree::visit::{Action, ChangeId, Relation},
17 Rewrites,
18};
19
20#[derive(Debug, Copy, Clone, Ord, PartialOrd, PartialEq, Eq)]
22pub enum ChangeKind {
23 Deletion,
25 Modification,
27 Addition,
29}
30
31pub trait Change: Clone {
33 fn id(&self) -> &gix_hash::oid;
38 fn relation(&self) -> Option<Relation>;
49 fn kind(&self) -> ChangeKind;
51 fn entry_mode(&self) -> EntryMode;
53 fn id_and_entry_mode(&self) -> (&gix_hash::oid, EntryMode);
55}
56
57pub(crate) struct Item<T> {
59 change: T,
61 path: Range<usize>,
63 emitted: bool,
65}
66
67impl<T: Change> Item<T> {
68 fn location<'a>(&self, backing: &'a [u8]) -> &'a BStr {
69 backing[self.path.clone()].as_ref()
70 }
71 fn entry_mode_compatible(&self, other: EntryMode) -> bool {
72 use EntryKind::*;
73 matches!(
74 (other.kind(), self.change.entry_mode().kind()),
75 (Blob | BlobExecutable, Blob | BlobExecutable) | (Link, Link) | (Tree, Tree)
76 )
77 }
78
79 fn is_source_for_destination_of(&self, kind: visit::SourceKind, dest_item_mode: EntryMode) -> bool {
80 self.entry_mode_compatible(dest_item_mode)
81 && match kind {
82 visit::SourceKind::Rename => !self.emitted && matches!(self.change.kind(), ChangeKind::Deletion),
83 visit::SourceKind::Copy => {
84 matches!(self.change.kind(), ChangeKind::Modification)
85 }
86 }
87 }
88}
89
90pub mod visit {
92 use bstr::BStr;
93 use gix_object::tree::EntryMode;
94
95 use crate::blob::DiffLineStats;
96
97 #[derive(Debug, Clone, PartialEq, PartialOrd)]
99 pub struct Source<'a, T> {
100 pub entry_mode: EntryMode,
102 pub id: gix_hash::ObjectId,
104 pub kind: SourceKind,
106 pub location: &'a BStr,
108 pub change: &'a T,
110 pub diff: Option<DiffLineStats>,
112 }
113
114 #[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
116 pub enum SourceKind {
117 Rename,
119 Copy,
121 }
122
123 #[derive(Debug, Clone)]
125 pub struct Destination<'a, T: Clone> {
126 pub change: T,
128 pub location: &'a BStr,
130 }
131}
132
133pub mod emit {
135 #[derive(Debug, thiserror::Error)]
137 #[allow(missing_docs)]
138 pub enum Error {
139 #[error("Could not find blob for similarity checking")]
140 FindExistingBlob(#[from] gix_object::find::existing_object::Error),
141 #[error("Could not obtain exhaustive item set to use as possible sources for copy detection")]
142 GetItemsForExhaustiveCopyDetection(#[source] Box<dyn std::error::Error + Send + Sync>),
143 #[error(transparent)]
144 SetResource(#[from] crate::blob::platform::set_resource::Error),
145 #[error(transparent)]
146 PrepareDiff(#[from] crate::blob::platform::prepare_diff::Error),
147 }
148}
149
150impl<T: Change> Tracker<T> {
152 pub fn new(rewrites: Rewrites) -> Self {
154 Tracker {
155 items: vec![],
156 path_backing: vec![],
157 rewrites,
158 child_renames: Default::default(),
159 }
160 }
161}
162
163impl<T: Change> Tracker<T> {
165 pub fn try_push_change(&mut self, change: T, location: &BStr) -> Option<T> {
167 let change_kind = change.kind();
168 if let (None, ChangeKind::Modification) = (self.rewrites.copies, change_kind) {
169 return Some(change);
170 }
171
172 let entry_kind = change.entry_mode().kind();
173 if entry_kind == EntryKind::Commit {
174 return Some(change);
175 }
176 let relation = change
177 .relation()
178 .filter(|_| matches!(change_kind, ChangeKind::Addition | ChangeKind::Deletion));
179 if let (None, EntryKind::Tree) = (relation, entry_kind) {
180 return Some(change);
181 }
182
183 let start = self.path_backing.len();
184 self.path_backing.extend_from_slice(location);
185 let path = start..self.path_backing.len();
186
187 self.items.push(Item {
188 path,
189 change,
190 emitted: false,
191 });
192 None
193 }
194
195 pub fn emit<PushSourceTreeFn, E>(
218 &mut self,
219 mut cb: impl FnMut(visit::Destination<'_, T>, Option<visit::Source<'_, T>>) -> Action,
220 diff_cache: &mut crate::blob::Platform,
221 objects: &impl gix_object::FindObjectOrHeader,
222 mut push_source_tree: PushSourceTreeFn,
223 ) -> Result<Outcome, emit::Error>
224 where
225 PushSourceTreeFn: FnMut(&mut dyn FnMut(T, &BStr)) -> Result<(), E>,
226 E: std::error::Error + Send + Sync + 'static,
227 {
228 fn is_parent(change: &impl Change) -> bool {
229 matches!(change.relation(), Some(Relation::Parent(_)))
230 }
231 diff_cache.options.skip_internal_diff_if_external_is_configured = false;
232
233 fn by_id_and_location<T: Change>(a: &Item<T>, b: &Item<T>) -> std::cmp::Ordering {
236 a.change
237 .id()
238 .cmp(b.change.id())
239 .then_with(|| a.path.start.cmp(&b.path.start).then(a.path.end.cmp(&b.path.end)))
240 }
241
242 let has_work = {
244 let (mut num_deletions, mut num_additions, mut num_modifications) = (0, 0, 0);
245 let mut has_work = false;
246 for change in &self.items {
247 match change.change.kind() {
248 ChangeKind::Deletion => {
249 num_deletions += 1;
250 }
251 ChangeKind::Modification => {
252 num_modifications += 1;
254 }
255 ChangeKind::Addition => num_additions += 1,
256 }
257 if (num_deletions != 0 && num_additions != 0)
258 || (self.rewrites.copies.is_some() && num_modifications + num_additions > 1)
259 {
260 has_work = true;
261 break;
262 }
263 }
264 has_work
265 };
266
267 let mut out = Outcome {
268 options: self.rewrites,
269 ..Default::default()
270 };
271 if has_work {
272 self.items.sort_by(by_id_and_location);
273
274 self.match_pairs_of_kind(
278 visit::SourceKind::Rename,
279 &mut cb,
280 None, &mut out,
282 diff_cache,
283 objects,
284 Some(is_parent),
285 )?;
286
287 self.match_pairs_of_kind(
288 visit::SourceKind::Rename,
289 &mut cb,
290 self.rewrites.percentage,
291 &mut out,
292 diff_cache,
293 objects,
294 None,
295 )?;
296
297 self.match_renamed_directories(&mut cb)?;
298
299 if let Some(copies) = self.rewrites.copies {
300 self.match_pairs_of_kind(
301 visit::SourceKind::Copy,
302 &mut cb,
303 copies.percentage,
304 &mut out,
305 diff_cache,
306 objects,
307 None,
308 )?;
309
310 match copies.source {
311 CopySource::FromSetOfModifiedFiles => {}
312 CopySource::FromSetOfModifiedFilesAndAllSources => {
313 push_source_tree(&mut |change, location| {
314 if self.try_push_change(change, location).is_none() {
315 self.items.last_mut().expect("just pushed").emitted = true;
317 }
318 })
319 .map_err(|err| emit::Error::GetItemsForExhaustiveCopyDetection(Box::new(err)))?;
320 self.items.sort_by(by_id_and_location);
321
322 self.match_pairs_of_kind(
323 visit::SourceKind::Copy,
324 &mut cb,
325 copies.percentage,
326 &mut out,
327 diff_cache,
328 objects,
329 None,
330 )?;
331 }
332 }
333 }
334 }
335
336 self.items
337 .sort_by(|a, b| a.location(&self.path_backing).cmp(b.location(&self.path_backing)));
338 for item in self.items.drain(..).filter(|item| !item.emitted) {
339 if cb(
340 visit::Destination {
341 location: item.location(&self.path_backing),
342 change: item.change,
343 },
344 None,
345 ) == Action::Cancel
346 {
347 break;
348 }
349 }
350 Ok(out)
351 }
352}
353
354impl<T: Change> Tracker<T> {
355 #[allow(clippy::too_many_arguments)]
356 fn match_pairs_of_kind(
357 &mut self,
358 kind: visit::SourceKind,
359 cb: &mut impl FnMut(visit::Destination<'_, T>, Option<visit::Source<'_, T>>) -> Action,
360 percentage: Option<f32>,
361 out: &mut Outcome,
362 diff_cache: &mut crate::blob::Platform,
363 objects: &impl gix_object::FindObjectOrHeader,
364 filter: Option<fn(&T) -> bool>,
365 ) -> Result<(), emit::Error> {
366 let needs_second_pass = !needs_exact_match(percentage);
368
369 if self.match_pairs(cb, None , kind, out, diff_cache, objects, filter)? == Action::Cancel {
373 return Ok(());
374 }
375 if needs_second_pass {
376 let is_limited = if self.rewrites.limit == 0 {
377 false
378 } else {
379 let (num_src, num_dst) =
380 estimate_involved_items(self.items.iter().map(|item| (item.emitted, item.change.kind())), kind);
381 let permutations = num_src * num_dst;
382 if permutations > self.rewrites.limit {
383 match kind {
384 visit::SourceKind::Rename => {
385 out.num_similarity_checks_skipped_for_rename_tracking_due_to_limit = permutations;
386 }
387 visit::SourceKind::Copy => {
388 out.num_similarity_checks_skipped_for_copy_tracking_due_to_limit = permutations;
389 }
390 }
391 true
392 } else {
393 false
394 }
395 };
396 if !is_limited {
397 self.match_pairs(cb, percentage, kind, out, diff_cache, objects, None)?;
398 }
399 }
400 Ok(())
401 }
402
403 #[allow(clippy::too_many_arguments)]
404 fn match_pairs(
405 &mut self,
406 cb: &mut impl FnMut(visit::Destination<'_, T>, Option<visit::Source<'_, T>>) -> Action,
407 percentage: Option<f32>,
408 kind: visit::SourceKind,
409 stats: &mut Outcome,
410 diff_cache: &mut crate::blob::Platform,
411 objects: &impl gix_object::FindObjectOrHeader,
412 filter: Option<fn(&T) -> bool>,
413 ) -> Result<Action, emit::Error> {
414 let mut dest_ofs = 0;
415 let mut num_checks = 0;
416 let max_checks = {
417 let limit = self.rewrites.limit.saturating_pow(2);
418 if self.items.len() < 100_000 {
422 0
423 } else {
424 limit
425 }
426 };
427
428 while let Some((mut dest_idx, dest)) = self.items[dest_ofs..].iter().enumerate().find_map(|(idx, item)| {
429 (!item.emitted
430 && matches!(item.change.kind(), ChangeKind::Addition)
431 && filter.map_or_else(
432 || {
433 self.rewrites.track_empty
434 || matches!(item.change.relation(), Some(Relation::ChildOfParent(_)))
437 || {
438 let id = item.change.id();
439 id != gix_hash::ObjectId::empty_blob(id.kind())
440 }
441 },
442 |f| f(&item.change),
443 ))
444 .then_some((idx, item))
445 }) {
446 dest_idx += dest_ofs;
447 dest_ofs = dest_idx + 1;
448 self.items[dest_idx].location(&self.path_backing);
449 let src = find_match(
450 &self.items,
451 dest,
452 dest_idx,
453 percentage,
454 kind,
455 stats,
456 objects,
457 diff_cache,
458 &self.path_backing,
459 &mut num_checks,
460 )?
461 .map(|(src_idx, src, diff)| {
462 let (id, entry_mode) = src.change.id_and_entry_mode();
463 let id = id.to_owned();
464 let location = src.location(&self.path_backing);
465 (
466 visit::Source {
467 entry_mode,
468 id,
469 kind,
470 location,
471 change: &src.change,
472 diff,
473 },
474 src_idx,
475 )
476 });
477 if max_checks != 0 && num_checks > max_checks {
478 gix_trace::warn!(
479 "Cancelled rename matching as there were too many iterations ({num_checks} > {max_checks})"
480 );
481 return Ok(Action::Cancel);
482 }
483 let Some((src, src_idx)) = src else {
484 continue;
485 };
486 let location = dest.location(&self.path_backing);
487 let change = dest.change.clone();
488 let dest = visit::Destination { change, location };
489 let relations = if percentage.is_none() {
490 src.change.relation().zip(dest.change.relation())
491 } else {
492 None
493 };
494 let res = cb(dest, Some(src));
495
496 self.items[dest_idx].emitted = true;
497 self.items[src_idx].emitted = true;
498
499 if res == Action::Cancel {
500 return Ok(Action::Cancel);
501 }
502
503 match relations {
504 Some((Relation::Parent(src), Relation::Parent(dst))) => {
505 let res = self.emit_child_renames_matching_identity(cb, kind, src, dst)?;
506 if res == Action::Cancel {
507 return Ok(Action::Cancel);
508 }
509 }
510 Some((Relation::ChildOfParent(src), Relation::ChildOfParent(dst))) => {
511 self.child_renames.insert((src, dst));
512 }
513 _ => {}
514 }
515 }
516 Ok(Action::Continue)
517 }
518
519 fn emit_child_renames_matching_identity(
523 &mut self,
524 cb: &mut impl FnMut(visit::Destination<'_, T>, Option<visit::Source<'_, T>>) -> Action,
525 kind: visit::SourceKind,
526 src_parent_id: ChangeId,
527 dst_parent_id: ChangeId,
528 ) -> Result<Action, emit::Error> {
529 debug_assert_ne!(
530 src_parent_id, dst_parent_id,
531 "src and destination directories must be distinct"
532 );
533 let (mut src_items, mut dst_items) = (Vec::with_capacity(1), Vec::with_capacity(1));
534 for item in self.items.iter_mut().filter(|item| !item.emitted) {
535 match item.change.relation() {
536 Some(Relation::ChildOfParent(id)) if id == src_parent_id => {
537 src_items.push((item.change.id().to_owned(), item));
538 }
539 Some(Relation::ChildOfParent(id)) if id == dst_parent_id => {
540 dst_items.push((item.change.id().to_owned(), item));
541 }
542 _ => continue,
543 }
544 }
545
546 for ((src_id, src_item), (dst_id, dst_item)) in src_items.into_iter().zip(dst_items) {
547 if src_id == dst_id
550 && filename(src_item.location(&self.path_backing)) == filename(dst_item.location(&self.path_backing))
551 {
552 let entry_mode = src_item.change.entry_mode();
553 let location = src_item.location(&self.path_backing);
554 let src = visit::Source {
555 entry_mode,
556 id: src_id,
557 kind,
558 location,
559 change: &src_item.change,
560 diff: None,
561 };
562 let location = dst_item.location(&self.path_backing);
563 let change = dst_item.change.clone();
564 let dst = visit::Destination { change, location };
565 let res = cb(dst, Some(src));
566
567 src_item.emitted = true;
568 dst_item.emitted = true;
569
570 if res == Action::Cancel {
571 return Ok(res);
572 }
573 } else {
574 gix_trace::warn!("Children of parents with change-id {src_parent_id} and {dst_parent_id} were not equal, even though their parents claimed to be");
575 break;
576 }
577 }
578 Ok(Action::Continue)
579 }
580
581 fn match_renamed_directories(
587 &mut self,
588 cb: &mut impl FnMut(visit::Destination<'_, T>, Option<visit::Source<'_, T>>) -> Action,
589 ) -> Result<(), emit::Error> {
590 fn unemitted_directory_matching_relation_id<T: Change>(items: &[Item<T>], child_id: ChangeId) -> Option<usize> {
591 items.iter().position(|i| {
592 !i.emitted && matches!(i.change.relation(), Some(Relation::Parent(pid)) if pid == child_id)
593 })
594 }
595 for (deleted_child_id, added_child_id) in &self.child_renames {
596 let Some(src_idx) = unemitted_directory_matching_relation_id(&self.items, *deleted_child_id) else {
597 continue;
598 };
599 let Some(dst_idx) = unemitted_directory_matching_relation_id(&self.items, *added_child_id) else {
600 continue;
603 };
604
605 let (src_item, dst_item) = (&self.items[src_idx], &self.items[dst_idx]);
606 let entry_mode = src_item.change.entry_mode();
607 let location = src_item.location(&self.path_backing);
608 let src = visit::Source {
609 entry_mode,
610 id: src_item.change.id().to_owned(),
611 kind: SourceKind::Rename,
612 location,
613 change: &src_item.change,
614 diff: None,
615 };
616 let location = dst_item.location(&self.path_backing);
617 let change = dst_item.change.clone();
618 let dst = visit::Destination { change, location };
619 let res = cb(dst, Some(src));
620
621 self.items[src_idx].emitted = true;
622 self.items[dst_idx].emitted = true;
623
624 if res == Action::Cancel {
625 return Ok(());
626 }
627 }
628 Ok(())
629 }
630}
631
632fn filename(path: &BStr) -> &BStr {
633 path.rfind_byte(b'/').map_or(path, |idx| path[idx + 1..].as_bstr())
634}
635
636fn estimate_involved_items(
638 items: impl IntoIterator<Item = (bool, ChangeKind)>,
639 kind: visit::SourceKind,
640) -> (usize, usize) {
641 items
642 .into_iter()
643 .filter(|(emitted, _)| match kind {
644 visit::SourceKind::Rename => !*emitted,
645 visit::SourceKind::Copy => true,
646 })
647 .fold((0, 0), |(mut src, mut dest), (emitted, change_kind)| {
648 match change_kind {
649 ChangeKind::Addition => {
650 if kind == visit::SourceKind::Rename || !emitted {
651 dest += 1;
652 }
653 }
654 ChangeKind::Deletion => {
655 if kind == visit::SourceKind::Rename {
656 src += 1;
657 }
658 }
659 ChangeKind::Modification => {
660 if kind == visit::SourceKind::Copy {
661 src += 1;
662 }
663 }
664 }
665 (src, dest)
666 })
667}
668
669fn needs_exact_match(percentage: Option<f32>) -> bool {
670 percentage.map_or(true, |p| p >= 1.0)
671}
672
673type SourceTuple<'a, T> = (usize, &'a Item<T>, Option<DiffLineStats>);
675
676#[allow(clippy::too_many_arguments)]
684fn find_match<'a, T: Change>(
685 items: &'a [Item<T>],
686 item: &Item<T>,
687 item_idx: usize,
688 percentage: Option<f32>,
689 kind: visit::SourceKind,
690 stats: &mut Outcome,
691 objects: &impl gix_object::FindObjectOrHeader,
692 diff_cache: &mut crate::blob::Platform,
693 path_backing: &[u8],
694 num_checks: &mut usize,
695) -> Result<Option<SourceTuple<'a, T>>, emit::Error> {
696 let (item_id, item_mode) = item.change.id_and_entry_mode();
697 if needs_exact_match(percentage) || item_mode.is_link() {
698 let first_idx = items.partition_point(|a| a.change.id() < item_id);
699 let range = items.get(first_idx..).map(|slice| {
700 let end = slice
701 .iter()
702 .position(|a| a.change.id() != item_id)
703 .map_or(items.len(), |idx| first_idx + idx);
704 first_idx..end
705 });
706 let range = match range {
707 Some(range) => range,
708 None => return Ok(None),
709 };
710 if range.is_empty() {
711 return Ok(None);
712 }
713 let res = items[range.clone()].iter().enumerate().find_map(|(mut src_idx, src)| {
714 src_idx += range.start;
715 *num_checks += 1;
716 (src_idx != item_idx && src.is_source_for_destination_of(kind, item_mode)).then_some((src_idx, src, None))
717 });
718 if let Some(src) = res {
719 return Ok(Some(src));
720 }
721 } else if item_mode.is_blob() {
722 let mut has_new = false;
723 let percentage = percentage.expect("it's set to something below 1.0 and we assured this");
724
725 for (can_idx, src) in items
726 .iter()
727 .enumerate()
728 .filter(|(src_idx, src)| *src_idx != item_idx && src.is_source_for_destination_of(kind, item_mode))
729 {
730 if !has_new {
731 diff_cache.set_resource(
732 item_id.to_owned(),
733 item_mode.kind(),
734 item.location(path_backing),
735 ResourceKind::NewOrDestination,
736 objects,
737 )?;
738 has_new = true;
739 }
740 let (src_id, src_mode) = src.change.id_and_entry_mode();
741 diff_cache.set_resource(
742 src_id.to_owned(),
743 src_mode.kind(),
744 src.location(path_backing),
745 ResourceKind::OldOrSource,
746 objects,
747 )?;
748 let prep = diff_cache.prepare_diff()?;
749 stats.num_similarity_checks += 1;
750 *num_checks += 1;
751 match prep.operation {
752 Operation::InternalDiff { algorithm } => {
753 let tokens =
754 crate::blob::intern::InternedInput::new(prep.old.intern_source(), prep.new.intern_source());
755 let counts = crate::blob::diff(
756 algorithm,
757 &tokens,
758 crate::blob::sink::Counter::new(diff::Statistics {
759 removed_bytes: 0,
760 input: &tokens,
761 }),
762 );
763 let old_data_len = prep.old.data.as_slice().unwrap_or_default().len();
764 let new_data_len = prep.new.data.as_slice().unwrap_or_default().len();
765 let similarity = (old_data_len - counts.wrapped) as f32 / old_data_len.max(new_data_len) as f32;
766 if similarity >= percentage {
767 return Ok(Some((
768 can_idx,
769 src,
770 DiffLineStats {
771 removals: counts.removals,
772 insertions: counts.insertions,
773 before: tokens.before.len().try_into().expect("interner handles only u32"),
774 after: tokens.after.len().try_into().expect("interner handles only u32"),
775 similarity,
776 }
777 .into(),
778 )));
779 }
780 }
781 Operation::ExternalCommand { .. } => {
782 unreachable!("we have disabled this possibility with an option")
783 }
784 Operation::SourceOrDestinationIsBinary => {
785 }
787 }
788 }
789 }
790 Ok(None)
791}
792
793mod diff {
794 use std::ops::Range;
795
796 pub struct Statistics<'a, 'data> {
797 pub removed_bytes: usize,
798 pub input: &'a crate::blob::intern::InternedInput<&'data [u8]>,
799 }
800
801 impl crate::blob::Sink for Statistics<'_, '_> {
802 type Out = usize;
803
804 fn process_change(&mut self, before: Range<u32>, _after: Range<u32>) {
805 self.removed_bytes += self.input.before[before.start as usize..before.end as usize]
806 .iter()
807 .map(|token| self.input.interner[*token].len())
808 .sum::<usize>();
809 }
810
811 fn finish(self) -> Self::Out {
812 self.removed_bytes
813 }
814 }
815}
816
817#[cfg(test)]
818mod estimate_involved_items {
819 use super::estimate_involved_items;
820 use crate::rewrites::tracker::{visit::SourceKind, ChangeKind};
821
822 #[test]
823 fn renames_count_unemitted_as_sources_and_destinations() {
824 let items = [
825 (false, ChangeKind::Addition),
826 (true, ChangeKind::Deletion),
827 (true, ChangeKind::Deletion),
828 ];
829 assert_eq!(
830 estimate_involved_items(items, SourceKind::Rename),
831 (0, 1),
832 "here we only have one eligible source, hence nothing to do"
833 );
834 assert_eq!(
835 estimate_involved_items(items.into_iter().map(|t| (false, t.1)), SourceKind::Rename),
836 (2, 1),
837 "now we have more possibilities as renames count un-emitted deletions as source"
838 );
839 }
840
841 #[test]
842 fn copies_do_not_count_additions_as_sources() {
843 let items = [
844 (false, ChangeKind::Addition),
845 (true, ChangeKind::Addition),
846 (true, ChangeKind::Deletion),
847 ];
848 assert_eq!(
849 estimate_involved_items(items, SourceKind::Copy),
850 (0, 1),
851 "one addition as source, the other isn't counted as it's emitted, nor is it considered a copy-source.\
852 deletions don't count"
853 );
854 }
855
856 #[test]
857 fn copies_count_modifications_as_sources() {
858 let items = [
859 (false, ChangeKind::Addition),
860 (true, ChangeKind::Modification),
861 (false, ChangeKind::Modification),
862 ];
863 assert_eq!(
864 estimate_involved_items(items, SourceKind::Copy),
865 (2, 1),
866 "any modifications is a valid source, emitted or not"
867 );
868 }
869}