use headwater_doc::body::{Body, Ownership};
use headwater_doc::Sentence;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Patch {
Text {
path: String,
start: usize,
end: usize,
expect: String,
replacement: String,
},
Half {
path: String,
relation: String,
id: String,
attributes: Vec<(String, String)>,
},
Create { path: String, contents: String },
}
impl Patch {
pub fn path(&self) -> &str {
match self {
Patch::Text { path, .. } => path,
Patch::Half { path, .. } => path,
Patch::Create { path, .. } => path,
}
}
}
pub fn substitution(
path: &str,
body: &Body,
sentence: &Sentence,
word: &str,
replacement: &str,
) -> Option<Patch> {
if word.is_empty() || word == replacement {
return None;
}
for block in &body.blocks {
if block.quote_depth > 0 {
continue;
}
for run in &block.runs {
if run.ownership != Ownership::Authored {
continue;
}
let (from, to) = (run.span.start.offset, run.span.end.offset);
let low = from.max(sentence.span.start.offset);
let high = to.min(sentence.span.end.offset);
if low >= high {
continue;
}
if to - from != run.text.len() {
continue;
}
let Some(at) = word_at(&run.text, word, (low - from)..(high - from)) else {
continue;
};
return Some(Patch::Text {
path: path.to_string(),
start: from + at,
end: from + at + word.len(),
expect: word.to_string(),
replacement: replacement.to_string(),
});
}
}
None
}
pub fn matching_case(written: &str, replacement: &str) -> String {
let raised = written.chars().next().is_some_and(char::is_uppercase)
&& !replacement.starts_with(char::is_uppercase);
if !raised {
return replacement.to_string();
}
let mut characters = replacement.chars();
match characters.next() {
None => replacement.to_string(),
Some(first) => first.to_uppercase().collect::<String>() + characters.as_str(),
}
}
fn word_at(text: &str, word: &str, window: std::ops::Range<usize>) -> Option<usize> {
if !text.is_char_boundary(window.start) || !text.is_char_boundary(window.end) {
return None;
}
let mut from = window.start;
while let Some(offset) = text.get(from..window.end)?.find(word) {
let at = from + offset;
let before = text[..at].chars().next_back();
let after = text[at + word.len()..].chars().next();
let bounded = |c: Option<char>| match c {
None => true,
Some(c) => !c.is_alphanumeric() && c != '\'' && c != '\u{2019}',
};
if bounded(before) && bounded(after) {
return Some(at);
}
from = at + word.len();
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use headwater_doc::body::scan;
fn sentence_patch(source: &str, word: &str, replacement: &str) -> Option<Patch> {
let body = scan(source, source, 0);
let sentences = body.sentences();
sentences
.iter()
.find_map(|sentence| substitution("d.md", &body, sentence, word, replacement))
}
#[test]
fn a_word_in_authored_prose_gets_the_offset_it_occupies() {
let source = "The behaviour of a check.\n";
let Some(Patch::Text {
start,
end,
expect,
replacement,
..
}) = sentence_patch(source, "behaviour", "behavior")
else {
panic!("no patch");
};
assert_eq!(&source[start..end], "behaviour");
assert_eq!(expect, "behaviour");
assert_eq!(replacement, "behavior");
}
#[test]
fn a_word_inside_a_code_span_gets_no_patch() {
assert_eq!(
sentence_patch("The `behaviour` flag.\n", "behaviour", "behavior"),
None
);
}
#[test]
fn a_word_inside_a_block_quote_gets_no_patch() {
assert_eq!(
sentence_patch("> The behaviour they describe.\n", "behaviour", "behavior"),
None
);
}
#[test]
fn a_word_inside_a_links_text_gets_the_offset_of_the_text() {
let source = "Read [the behaviour note](x.md) first.\n";
let Some(Patch::Text { start, end, .. }) = sentence_patch(source, "behaviour", "behavior")
else {
panic!("no patch");
};
assert_eq!(&source[start..end], "behaviour");
assert_eq!(start, source.find("behaviour").unwrap());
}
#[test]
fn a_sentence_is_a_window_on_the_run_that_holds_it() {
let source = "First is fine. The behaviour follows.\n";
let body = scan(source, source, 0);
let sentences = body.sentences();
assert_eq!(sentences.len(), 2);
assert_eq!(
substitution("d.md", &body, &sentences[0], "behaviour", "behavior"),
None
);
let Some(Patch::Text { start, .. }) =
substitution("d.md", &body, &sentences[1], "behaviour", "behavior")
else {
panic!("no patch for the sentence that writes it");
};
assert_eq!(start, source.find("behaviour").unwrap());
}
#[test]
fn a_word_that_is_only_part_of_a_longer_word_gets_no_patch() {
assert_eq!(
sentence_patch("Two licences here.\n", "licence", "license"),
None
);
}
#[test]
fn an_entity_beside_a_word_does_not_move_the_words_offset() {
let source = "Ampersand & behaviour together.\n";
let Some(Patch::Text { start, end, .. }) = sentence_patch(source, "behaviour", "behavior")
else {
panic!("no patch");
};
assert_eq!(&source[start..end], "behaviour");
}
#[test]
fn a_run_that_reads_fewer_bytes_than_it_spans_gets_no_patch() {
use headwater_doc::body::{Block, BlockKind, Run};
use headwater_doc::{Position, Span};
let run = Run {
text: "the behaviour of it".to_string(),
span: Span::new(Position::new(1, 1, 0), Position::new(1, 22, 21)),
ownership: Ownership::Authored,
};
let block = Block {
kind: BlockKind::Paragraph,
span: run.span,
quote_depth: 0,
runs: vec![run],
soft_breaks: Vec::new(),
info: None,
};
let body = Body {
blocks: vec![block],
links: Vec::new(),
};
let sentence = &body.sentences()[0];
assert_eq!(
substitution("d.md", &body, sentence, "behaviour", "behavior"),
None
);
}
}