Skip to main content

documents/proposal/
slices.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5// Portions copyright (c) 2026 2youg1 and the RefRain contributors
6
7//! The sentences of a text with the whitespace around them, and the
8//! alignment of two such lists (`crates/documents/Spec.lean` D14, D15).
9//!
10//! Migrated from RefRain's `manuscript::review` (the sentence cut) and
11//! `manuscript::align` (the common-subsequence table). Two things moved:
12//! a sentence is compared with its whitespace, so the sentences of either
13//! side read back that side byte for byte; and the anchored segmentation
14//! RefRain needs for a whole manuscript is not here, because a proposal
15//! is at most one window on each side.
16
17use super::{Slice, SliceKind};
18
19/// How many cells the common-subsequence table may hold: `u32` each,
20/// sixteen MiB in all (D15). Past it the middle of the two lists is one
21/// deletion and one insertion: still exact, only coarser.
22pub const ALIGN_CELLS_MAX: usize = 1 << 22;
23
24/// One sentence: the whitespace before it, the sentence, and - for the
25/// last sentence of a text only - the whitespace after it.
26#[derive(Debug, Clone, PartialEq, Eq, Default)]
27pub(super) struct Piece {
28    lead: String,
29    text: String,
30    trail: String,
31}
32
33impl Piece {
34    fn push(&mut self, character: char) {
35        if !character.is_whitespace() {
36            self.text.push_str(&self.trail);
37            self.trail.clear();
38            self.text.push(character);
39        } else if self.text.is_empty() {
40            self.lead.push(character);
41        } else {
42            self.trail.push(character);
43        }
44    }
45
46    fn is_blank(&self) -> bool {
47        self.text.is_empty()
48    }
49
50    fn into_slice(self, kind: SliceKind) -> Slice {
51        Slice {
52            kind,
53            text: self.text,
54            lead: self.lead,
55            trail: self.trail,
56        }
57    }
58}
59
60/// Cuts `text` into sentences that read back as `text`, byte for byte.
61///
62/// A sentence ends at a terminator, taking with it the terminators and
63/// closing marks that follow at once. Whitespace after the last
64/// sentence is that sentence's trail; a text of whitespace alone is one
65/// sentence with an empty `text`.
66pub(super) fn pieces(text: &str) -> Vec<Piece> {
67    let mut found = Vec::new();
68    let mut building = Piece::default();
69    let mut characters = text.chars().peekable();
70    while let Some(character) = characters.next() {
71        building.push(character);
72        if is_terminator(character) {
73            while let Some(&next) = characters.peek() {
74                if !is_terminator(next) && !is_closer(next) {
75                    break;
76                }
77                building.push(next);
78                characters.next();
79            }
80            found.push(std::mem::take(&mut building));
81        }
82    }
83    if building != Piece::default() {
84        match found.last_mut() {
85            Some(last) if building.is_blank() => last.trail.push_str(&building.lead),
86            Some(_) | None => found.push(building),
87        }
88    }
89    found
90}
91
92fn is_terminator(character: char) -> bool {
93    matches!(character, '。' | '!' | '?' | '…' | '!' | '?' | '.')
94}
95
96fn is_closer(character: char) -> bool {
97    matches!(
98        character,
99        '"' | '\'' | '\u{201d}' | '\u{2019}' | ')' | ')' | '」' | '』'
100    )
101}
102
103/// The two lists as one card: what both hold, what only the first holds,
104/// what only the second holds, in reading order.
105pub(super) fn align(before: Vec<Piece>, after: Vec<Piece>) -> Vec<Slice> {
106    let head = before
107        .iter()
108        .zip(after.iter())
109        .take_while(|(one, other)| one == other)
110        .count();
111    let tail = before
112        .iter()
113        .skip(head)
114        .rev()
115        .zip(after.iter().skip(head).rev())
116        .take_while(|(one, other)| one == other)
117        .count();
118    let middle = |pieces: &[Piece]| -> Vec<Piece> {
119        pieces
120            .iter()
121            .skip(head)
122            .take(pieces.len().saturating_sub(head).saturating_sub(tail))
123            .cloned()
124            .collect()
125    };
126    let (gone, come) = (middle(&before), middle(&after));
127    let mut slices: Vec<Slice> = before
128        .iter()
129        .take(head)
130        .cloned()
131        .map(|piece| piece.into_slice(SliceKind::Same))
132        .collect();
133    slices.extend(middle_slices(gone, come));
134    slices.extend(
135        before
136            .into_iter()
137            .skip(head)
138            .rev()
139            .take(tail)
140            .rev()
141            .map(|piece| piece.into_slice(SliceKind::Same)),
142    );
143    slices
144}
145
146/// The middle of the two lists, aligned by their longest common
147/// subsequence, or deleted then inserted whole past the table's budget.
148fn middle_slices(gone: Vec<Piece>, come: Vec<Piece>) -> Vec<Slice> {
149    let Some(table) = Table::of(&gone, &come) else {
150        return gone
151            .into_iter()
152            .map(|piece| piece.into_slice(SliceKind::Delete))
153            .chain(
154                come.into_iter()
155                    .map(|piece| piece.into_slice(SliceKind::Insert)),
156            )
157            .collect();
158    };
159    let mut slices = Vec::with_capacity(gone.len().saturating_add(come.len()));
160    let mut gone = gone.into_iter().enumerate().peekable();
161    let mut come = come.into_iter().enumerate().peekable();
162    while let (Some((left, one)), Some((right, other))) = (gone.peek(), come.peek()) {
163        if one == other {
164            slices.push(one.clone().into_slice(SliceKind::Same));
165            gone.next();
166            come.next();
167        } else if table.at(left.saturating_add(1), *right)
168            >= table.at(*left, right.saturating_add(1))
169        {
170            slices.push(one.clone().into_slice(SliceKind::Delete));
171            gone.next();
172        } else {
173            slices.push(other.clone().into_slice(SliceKind::Insert));
174            come.next();
175        }
176    }
177    slices.extend(gone.map(|(_, piece)| piece.into_slice(SliceKind::Delete)));
178    slices.extend(come.map(|(_, piece)| piece.into_slice(SliceKind::Insert)));
179    slices
180}
181
182/// The length of the longest common subsequence of every pair of
183/// suffixes, `width` columns to a row.
184struct Table {
185    cells: Vec<u32>,
186    width: usize,
187}
188
189impl Table {
190    /// The table for two lists, or `None` when it would pass
191    /// [`ALIGN_CELLS_MAX`].
192    fn of(gone: &[Piece], come: &[Piece]) -> Option<Table> {
193        let width = come.len();
194        let size = gone.len().checked_mul(width)?;
195        if size > ALIGN_CELLS_MAX {
196            return None;
197        }
198        let mut table = Table {
199            cells: vec![0; size],
200            width,
201        };
202        for (left, one) in gone.iter().enumerate().rev() {
203            for (right, other) in come.iter().enumerate().rev() {
204                let value = if one == other {
205                    table
206                        .at(left.saturating_add(1), right.saturating_add(1))
207                        .saturating_add(1)
208                } else {
209                    table
210                        .at(left.saturating_add(1), right)
211                        .max(table.at(left, right.saturating_add(1)))
212                };
213                if let Some(cell) = table
214                    .cell(left, right)
215                    .and_then(|at| table.cells.get_mut(at))
216                {
217                    *cell = value;
218                }
219            }
220        }
221        Some(table)
222    }
223
224    /// The common length of the suffixes from `left` and `right`. A
225    /// suffix past the end of either list is empty, and an empty suffix
226    /// has nothing in common with anything: that is what zero states.
227    fn at(&self, left: usize, right: usize) -> u32 {
228        self.cell(left, right)
229            .and_then(|at| self.cells.get(at))
230            .copied()
231            .unwrap_or(0)
232    }
233
234    fn cell(&self, left: usize, right: usize) -> Option<usize> {
235        (right < self.width)
236            .then(|| left.checked_mul(self.width)?.checked_add(right))
237            .flatten()
238    }
239}