use crate::diff::text_range::{SourceText, TextRange};
#[derive(Debug, Clone, PartialEq)]
pub struct RangeMatch {
pub source: TextRange,
pub destination: TextRange,
pub operation: TextOperation,
}
impl RangeMatch {
pub fn zero() -> Self {
RangeMatch {
source: TextRange::zero(),
destination: TextRange::zero(),
operation: TextOperation::NotYetSet,
}
}
pub fn is_zero(&self) -> bool {
self.source.is_zero()
&& self.destination.is_zero()
&& self.operation == TextOperation::NotYetSet
}
pub fn extends(
&self,
other: &RangeMatch,
source_code: &SourceText,
dest_code: &SourceText,
) -> bool {
if self.operation != other.operation {
return false;
}
self.source
.can_extend_with_whitespace(&other.source, source_code)
&& self
.destination
.can_extend_with_whitespace(&other.destination, dest_code)
}
pub fn extend_into(&mut self, other: &RangeMatch) {
self.source.extend_to_end(&other.source);
self.destination.extend_to_end(&other.destination);
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub enum TextOperation {
#[default]
NotYetSet,
Identical,
Move,
Update,
Insert,
Delete,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct RenderOptions {
pub leading_whitespace: bool,
pub structural_punctuation: bool,
#[serde(default)]
pub whole_pair_updates: bool,
#[serde(default = "paint_reindent_only_moves_default")]
pub paint_reindent_only_moves: bool,
#[serde(default = "paint_displaced_moves_default")]
pub paint_displaced_moves: bool,
#[serde(default = "paint_resized_moves_default")]
pub paint_resized_moves: bool,
#[serde(default)]
pub whole_identifier_updates: bool,
}
pub(crate) fn paint_reindent_only_moves_default() -> bool {
true
}
pub(crate) fn paint_displaced_moves_default() -> bool {
true
}
pub(crate) fn paint_resized_moves_default() -> bool {
true
}
impl RenderOptions {
pub const MINIMAL: Self = Self {
leading_whitespace: false,
structural_punctuation: false,
whole_pair_updates: false,
paint_reindent_only_moves: false,
paint_displaced_moves: false,
paint_resized_moves: false,
whole_identifier_updates: false,
};
pub const FULL: Self = Self {
leading_whitespace: true,
structural_punctuation: true,
whole_pair_updates: false,
paint_reindent_only_moves: true,
paint_displaced_moves: true,
paint_resized_moves: true,
whole_identifier_updates: true,
};
pub fn options(&self) -> [(&'static str, bool); 7] {
[
("Leading whitespace", self.leading_whitespace),
(
"Structural punctuation (brackets, separators)",
self.structural_punctuation,
),
("Whole-pair updates", self.whole_pair_updates),
("Paint reindent-only moves", self.paint_reindent_only_moves),
("Paint displaced moves", self.paint_displaced_moves),
(
"Paint moves the two sides size differently",
self.paint_resized_moves,
),
("Whole renamed identifiers", self.whole_identifier_updates),
]
}
pub fn toggle(&mut self, i: usize) {
match i {
0 => self.leading_whitespace = !self.leading_whitespace,
1 => self.structural_punctuation = !self.structural_punctuation,
2 => self.whole_pair_updates = !self.whole_pair_updates,
3 => self.paint_reindent_only_moves = !self.paint_reindent_only_moves,
4 => self.paint_displaced_moves = !self.paint_displaced_moves,
5 => self.paint_resized_moves = !self.paint_resized_moves,
6 => self.whole_identifier_updates = !self.whole_identifier_updates,
_ => {}
}
}
pub fn needs_rebuild_from(&self, previous: &Self) -> bool {
let post_filters_only = Self {
leading_whitespace: previous.leading_whitespace,
structural_punctuation: previous.structural_punctuation,
..*self
};
post_filters_only != *previous
}
}
impl Default for RenderOptions {
fn default() -> Self {
Self::FULL
}
}
pub(crate) const STRUCTURAL_PUNCTUATION: &[char] = &['(', ')', '[', ']', '{', '}', ',', ';', ':'];
pub fn is_structural_only(text: &str) -> bool {
!text.is_empty()
&& text
.chars()
.all(|c| c.is_whitespace() || STRUCTURAL_PUNCTUATION.contains(&c))
}
pub fn ranges_for_options(
ranges: &[RangeMatch],
source: &str,
options: RenderOptions,
) -> Vec<RangeMatch> {
let result = ranges_for_options_impl(ranges, source, options);
if options.structural_punctuation {
return result;
}
restore_paired_brackets(ranges, source, options, result)
}
pub(crate) fn ranges_for_options_impl(
ranges: &[RangeMatch],
source: &str,
options: RenderOptions,
) -> Vec<RangeMatch> {
let lines: Vec<&str> = source.split('\n').collect();
let mut matched_rows = std::collections::HashSet::new();
for range_match in ranges {
if !matches!(
range_match.operation,
TextOperation::Insert | TextOperation::Delete
) {
let source = &range_match.source;
let last_touched_row = if source.end_column == 0 && source.end_row > source.start_row {
source.end_row - 1
} else {
source.end_row
};
matched_rows.extend(source.start_row..=last_touched_row);
}
}
ranges
.iter()
.flat_map(|range_match| {
if range_match.operation == TextOperation::Identical
|| range_match.operation == TextOperation::NotYetSet
{
return vec![range_match.clone()];
}
if !options.leading_whitespace
&& matches!(
range_match.operation,
TextOperation::Insert | TextOperation::Delete
)
&& range_match.source.start_row != range_match.source.end_row
{
return split_into_per_row_pieces(&lines, range_match)
.into_iter()
.filter(|piece| {
options.structural_punctuation
|| range_is_structural_only(&lines, &piece.source) != Some(true)
})
.collect();
}
narrow_one_range(&lines, &matched_rows, options, range_match)
.into_iter()
.collect()
})
.collect()
}
pub(crate) fn restore_paired_brackets(
ranges: &[RangeMatch],
source: &str,
options: RenderOptions,
mut result: Vec<RangeMatch>,
) -> Vec<RangeMatch> {
let with_structural_kept = ranges_for_options_impl(
ranges,
source,
RenderOptions {
structural_punctuation: true,
..options
},
);
let dropped: Vec<&RangeMatch> = with_structural_kept
.iter()
.filter(|candidate| {
!result.contains(candidate)
&& matches!(candidate.operation, TextOperation::Insert | TextOperation::Delete)
})
.collect();
if dropped.is_empty() {
return result;
}
let text = SourceText::new(source);
let byte_range = |range: &TextRange| -> Option<(usize, usize)> {
let start = text.byte_index(
crate::diff::text_range::SourceRow::from_raw(range.start_row),
crate::diff::text_range::SourceColumn::from_raw(range.start_column),
)?;
let end = text.byte_index(
crate::diff::text_range::SourceRow::from_raw(range.end_row),
crate::diff::text_range::SourceColumn::from_raw(range.end_column),
)?;
Some((start.get(), end.get()))
};
let partners = bracket_pair_partners(source);
let covered = |byte: usize| {
result
.iter()
.any(|kept| byte_range(&kept.source).is_some_and(|(s, e)| s <= byte && byte < e))
};
let mut restored = Vec::new();
for candidate in dropped {
let Some((start, end)) = byte_range(&candidate.source) else {
continue;
};
let Some(text) = source.get(start..end) else {
continue;
};
let has_a_surviving_partner = text
.char_indices()
.filter(|&(_, c)| matches!(c, '(' | ')' | '[' | ']' | '{' | '}'))
.any(|(i, _)| {
partners
.get(&(start + i))
.is_some_and(|&partner_byte| covered(partner_byte))
});
if has_a_surviving_partner {
restored.push((*candidate).clone());
}
}
result.extend(restored);
result
}
pub(crate) fn bracket_pair_partners(source: &str) -> std::collections::HashMap<usize, usize> {
let mut partners = std::collections::HashMap::new();
let mut stack: Vec<(char, usize)> = Vec::new();
for (byte, ch) in source.char_indices() {
match ch {
'(' | '[' | '{' => stack.push((ch, byte)),
')' | ']' | '}' => {
if let Some(&(open_ch, open_byte)) = stack.last()
&& matches!((open_ch, ch), ('(', ')') | ('[', ']') | ('{', '}'))
{
stack.pop();
partners.insert(open_byte, byte);
partners.insert(byte, open_byte);
}
}
_ => {}
}
}
partners
}
pub(crate) fn narrow_one_range(
lines: &[&str],
matched_rows: &std::collections::HashSet<usize>,
options: RenderOptions,
range_match: &RangeMatch,
) -> Option<RangeMatch> {
if !options.structural_punctuation {
match range_is_structural_only(lines, &range_match.source) {
Some(true) => return None,
Some(false) => {}
None => return Some(range_match.clone()),
}
}
let mut trimmed = range_match.clone();
trimmed.source = trim_trailing_whitespace(lines, &range_match.source)?;
if options.leading_whitespace {
if matches!(
range_match.operation,
TextOperation::Insert | TextOperation::Delete
) && !matched_rows.contains(&trimmed.source.start_row)
{
trimmed.source = extend_leading_whitespace(lines, &trimmed.source);
}
} else {
trimmed.source = trim_leading_whitespace(lines, &trimmed.source)?;
}
Some(trimmed)
}
pub(crate) fn split_into_per_row_pieces(
lines: &[&str],
range_match: &RangeMatch,
) -> Vec<RangeMatch> {
let source = &range_match.source;
(source.start_row..=source.end_row)
.filter_map(|row| {
let line = *lines.get(row)?;
let start_column = if row == source.start_row {
source.start_column
} else {
0
}
.min(line.len());
let end_column = if row == source.end_row {
source.end_column
} else {
line.len()
}
.min(line.len());
let row_range = TextRange::new(row, start_column, row, end_column);
let row_range = trim_trailing_whitespace(lines, &row_range)?;
let row_range = trim_leading_whitespace(lines, &row_range)?;
Some(RangeMatch {
source: row_range,
destination: range_match.destination.clone(),
operation: range_match.operation.clone(),
})
})
.collect()
}
pub(crate) fn trim_trailing_whitespace(lines: &[&str], range: &TextRange) -> Option<TextRange> {
let (start_row, start_column) = (range.start_row, range.start_column);
let (mut end_row, mut end_column) = (range.end_row, range.end_column);
loop {
if (start_row, start_column) >= (end_row, end_column) {
return None;
}
if end_row >= lines.len() {
end_row = end_row.checked_sub(1)?;
end_column = lines.get(end_row)?.len();
continue;
}
let line = *lines.get(end_row)?;
let column = end_column.min(line.len());
match line[..column].chars().next_back() {
Some(c) if c.is_whitespace() => end_column = column - c.len_utf8(),
None => {
end_row = end_row.checked_sub(1)?;
end_column = lines.get(end_row)?.len();
}
Some(_) => break,
}
}
Some(TextRange::new(start_row, start_column, end_row, end_column))
}
pub(crate) fn trim_leading_whitespace(lines: &[&str], range: &TextRange) -> Option<TextRange> {
let (mut start_row, mut start_column) = (range.start_row, range.start_column);
let (end_row, end_column) = (range.end_row, range.end_column);
loop {
if (start_row, start_column) >= (end_row, end_column) {
return None;
}
let line = *lines.get(start_row)?;
match line[start_column.min(line.len())..].chars().next() {
Some(c) if c.is_whitespace() => start_column += c.len_utf8(),
None => {
start_row += 1;
start_column = 0;
}
Some(_) => break,
}
}
Some(TextRange::new(start_row, start_column, end_row, end_column))
}
pub(crate) fn extend_leading_whitespace(lines: &[&str], range: &TextRange) -> TextRange {
let Some(line) = lines.get(range.start_row) else {
return range.clone();
};
let prefix_end = range.start_column.min(line.len());
if line[..prefix_end].chars().all(char::is_whitespace) {
TextRange::new(range.start_row, 0, range.end_row, range.end_column)
} else {
range.clone()
}
}
pub(crate) fn range_is_structural_only(lines: &[&str], range: &TextRange) -> Option<bool> {
if range.start_row > range.end_row {
return None;
}
let mut saw_any = false;
for row in range.start_row..=range.end_row {
let line = *lines.get(row)?;
let start = if row == range.start_row {
range.start_column
} else {
0
};
let end = if row == range.end_row {
range.end_column
} else {
line.len()
};
if start > line.len() || end > line.len() || start > end {
return None;
}
let covered = line.get(start..end)?;
if !covered.is_empty() {
saw_any = true;
if !is_structural_only(covered) {
return Some(false);
}
}
if row < range.end_row {
saw_any = true;
}
}
Some(saw_any)
}