v3 : sixième relecture — une coquille touche les lettres et les espaces, rien d'autre
- La forme du texte (tout ce qui n'est ni lettre latine ni espace, dans l'ordre, avec les espaces qui touchent un chiffre) ne bouge plus : ponctuation, emphase, signes, parenthèses passent par une proposition (un signe moins devant un nombre en gras, deux conditions fusionnées, une parenthèse déplacée). Apostrophes et guillemets de tous styles comptent pour un même signe. - Les espaces ne font que coller ou couper des mots, jamais déplacer une frontière (« les avoir » → « le savoir »). - Identifiants qui contiennent un chiffre (clés, références) comparés à l'identique, casse comprise, comme les adresses. - Correction écrite seulement sur le texte jugé (révision, statut, texte) : sinon conflit « changed » — plus de course avec une entrée en vigueur. - Déploiement : « previous » n'est pas repris d'un pipeline annulé. - Interface (fr/en/es) : la note de correction dit la règle telle qu'elle est. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,12 +3,12 @@
|
||||
//! Once a proposal is under vote, its author may still correct it. The engine
|
||||
//! tells two kinds of corrections apart:
|
||||
//!
|
||||
//! - **typographic**: accents, case, punctuation, spacing, emphasis, or at most
|
||||
//! two misspelt words fixed by a single slip each (one letter added, removed
|
||||
//! or swapped, never at the start of the word, never on a negation or a
|
||||
//! quantity) — and never a figure, a sign, a separator inside a number, a
|
||||
//! relation, a bracket or anything between backticks. The votes stay; the
|
||||
//! correction is shown with its diff.
|
||||
//! - **typographic**: letters and spaces only — accents, case, the style of
|
||||
//! apostrophes and quotes, words glued or split, or at most two misspelt
|
||||
//! words fixed by a single slip each (one letter added, removed or swapped
|
||||
//! in the middle of the word, never on a negation or a quantity). Never a
|
||||
//! punctuation mark, a figure, a sign, an emphasis, a formula or an address.
|
||||
//! The votes stay; the correction is shown with its diff.
|
||||
//! - **substantive**: anything else. It opens a new revision and every vote
|
||||
//! cast on the previous one waits for its author to reconfirm it.
|
||||
//!
|
||||
@@ -87,13 +87,8 @@ fn is_latin_letter(c: char) -> bool {
|
||||
c.is_alphabetic() && matches!(c as u32, 0x41..=0x5A | 0x61..=0x7A | 0xC0..=0x24F | 0x1E00..=0x1EFF)
|
||||
}
|
||||
|
||||
/// Ordinary punctuation, free to change in a typo fix — except where noted below.
|
||||
const PUNCTUATION: &[char] =
|
||||
&['.', ',', ';', ':', '!', '?', '\'', '’', '‘', '"', '“', '”', '«', '»', '…', '‐', '-', '–', '—', '*', '_'];
|
||||
/// The only spaces free to change; any other (hair, thin, ogham…) is kept as it is.
|
||||
const FREE_SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}', '\n', '\r', '\t'];
|
||||
/// Dashes become a sign when a figure follows or precedes, even across spaces ("- 5").
|
||||
const DASHES: &[char] = &['‐', '-', '–', '—'];
|
||||
|
||||
/// Invisible format characters (zero-width, bidirectional overrides and
|
||||
/// isolates, byte order mark): they can make "12" display as "21" ("Trojan
|
||||
@@ -104,42 +99,43 @@ pub fn has_hidden_controls(s: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Everything a typo fix may not change, in order. Built the other way round
|
||||
/// from a list of dangerous symbols: what is kept is everything that is NOT a
|
||||
/// Latin letter, a space or ordinary punctuation — figures in any script, signs,
|
||||
/// relations, brackets, look-alikes (U+201A for a comma, U+2044 for a slash,
|
||||
/// full-width forms), stray combining marks (U+0336, U+0338), other alphabets.
|
||||
/// Anything that touches a figure counts too, spaces and punctuation included:
|
||||
/// "5,0", "5,\u{202f}0", "1:5", "1…5" never read like "50" or "15".
|
||||
fn signature(s: &str) -> String {
|
||||
/// Apostrophes and quotation marks of every style are one and the same sign:
|
||||
/// « Je m'engage » → « Je m’engage » is typography.
|
||||
fn same_mark(c: char) -> char {
|
||||
match c {
|
||||
'’' | '‘' | 'ʼ' => '\'',
|
||||
'“' | '”' | '«' | '»' => '"',
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// The shape of a text: every run of Latin letters becomes one mark, free spaces
|
||||
/// vanish, and **everything else stays as it is, in order** — punctuation,
|
||||
/// figures, signs, emphasis, brackets, look-alikes, other alphabets. A typo fix
|
||||
/// keeps the shape: it touches letters and spaces only (2026-10-21, after six
|
||||
/// independent reviews showed that any free punctuation can carry a meaning:
|
||||
/// a minus sign before a bold number, a list merged into one condition…).
|
||||
fn shape(s: &str) -> String {
|
||||
let cs: Vec<char> = s.nfc().collect();
|
||||
let is_figure = |i: Option<usize>| i.and_then(|i| cs.get(i)).is_some_and(|c| c.is_numeric());
|
||||
// The next character that is not a free space, on either side.
|
||||
let across = |i: usize, step: isize| {
|
||||
let mut j = i as isize + step;
|
||||
while j >= 0 && (j as usize) < cs.len() && FREE_SPACES.contains(&cs[j as usize]) {
|
||||
j += step;
|
||||
}
|
||||
j >= 0 && is_figure(Some(j as usize))
|
||||
};
|
||||
cs.iter()
|
||||
.enumerate()
|
||||
.filter(|&(i, &c)| {
|
||||
let touches_figure = is_figure(i.checked_sub(1)) || is_figure(Some(i + 1));
|
||||
if is_latin_letter(c) {
|
||||
false
|
||||
} else if touches_figure {
|
||||
true
|
||||
} else if FREE_SPACES.contains(&c) {
|
||||
false
|
||||
} else if DASHES.contains(&c) {
|
||||
across(i, -1) || across(i, 1)
|
||||
} else {
|
||||
!PUNCTUATION.contains(&c)
|
||||
let mut out = String::new();
|
||||
let mut in_word = false;
|
||||
for (i, &c) in cs.iter().enumerate() {
|
||||
if is_latin_letter(c) {
|
||||
if !in_word {
|
||||
out.push('a');
|
||||
}
|
||||
})
|
||||
.map(|(_, &c)| c)
|
||||
.collect()
|
||||
in_word = true;
|
||||
} else if FREE_SPACES.contains(&c) && !is_figure(i.checked_sub(1)) && !is_figure(Some(i + 1)) {
|
||||
// A space between letters may come and go: « latransmission » and
|
||||
// « la transmission » share a shape (segmentation is checked apart).
|
||||
// Next to a figure it stays: « 1 5 » is not « 15 ».
|
||||
} else {
|
||||
out.push(same_mark(c));
|
||||
in_word = false;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// What stands between backticks: formulas and identifiers, compared exactly.
|
||||
@@ -147,15 +143,16 @@ fn code_spans(s: &str) -> Vec<&str> {
|
||||
s.split('`').skip(1).step_by(2).collect()
|
||||
}
|
||||
|
||||
/// Web and mail addresses, domains, paths: compared exactly ("duniter" /
|
||||
/// "dunniter" is a typo of a word, not of an address).
|
||||
fn addresses(s: &str) -> Vec<String> {
|
||||
/// Addresses (links, domains, mails, paths) and identifiers that hold a figure
|
||||
/// (keys, fingerprints, references): compared exactly, case included.
|
||||
fn exact_tokens(s: &str) -> Vec<String> {
|
||||
s.split_whitespace()
|
||||
.map(|t| t.trim_matches(|c: char| !c.is_alphanumeric()))
|
||||
.filter(|t| {
|
||||
let cs: Vec<char> = t.chars().collect();
|
||||
t.contains("://")
|
||||
|| t.contains('@')
|
||||
|| cs.iter().any(|c| c.is_numeric()) && cs.iter().any(|c| c.is_alphabetic())
|
||||
|| (1..cs.len().saturating_sub(1))
|
||||
.any(|i| matches!(cs[i], '.' | '/') && cs[i - 1].is_alphanumeric() && cs[i + 1].is_alphanumeric())
|
||||
})
|
||||
@@ -199,6 +196,24 @@ fn words(s: &str) -> Vec<String> {
|
||||
fold(s).split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).map(str::to_owned).collect()
|
||||
}
|
||||
|
||||
/// Same letters, words only glued or split: « latransmission » → « la
|
||||
/// transmission ». A boundary moved is not a typo (« les avoir » → « le savoir »).
|
||||
fn only_glued_or_split(a: &[String], b: &[String]) -> bool {
|
||||
let (mut i, mut j) = (0, 0);
|
||||
while i < a.len() && j < b.len() {
|
||||
if a[i] == b[j] {
|
||||
(i, j) = (i + 1, j + 1);
|
||||
} else if j + 1 < b.len() && a[i] == format!("{}{}", b[j], b[j + 1]) {
|
||||
(i, j) = (i + 1, j + 2);
|
||||
} else if i + 1 < a.len() && b[j] == format!("{}{}", a[i], a[i + 1]) {
|
||||
(i, j) = (i + 2, j + 1);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
i == a.len() && j == b.len()
|
||||
}
|
||||
|
||||
/// Classify `new` against `old`. For a text under vote, `old` must be the text
|
||||
/// that was **voted** (its last initial or substantive revision), not the
|
||||
/// current one: typo fixes are measured together, so slips never add up.
|
||||
@@ -207,16 +222,17 @@ pub fn classify(old: &str, new: &str) -> EditKind {
|
||||
return EditKind::Unchanged;
|
||||
}
|
||||
if has_hidden_controls(new)
|
||||
|| signature(old) != signature(new)
|
||||
|| shape(old) != shape(new)
|
||||
|| code_spans(old) != code_spans(new)
|
||||
|| addresses(old) != addresses(new)
|
||||
|| exact_tokens(old) != exact_tokens(new)
|
||||
{
|
||||
return EditKind::Substantive;
|
||||
}
|
||||
if skeleton(old) == skeleton(new) {
|
||||
return EditKind::Typographic;
|
||||
}
|
||||
let (a, b) = (words(old), words(new));
|
||||
if skeleton(old) == skeleton(new) {
|
||||
// Accents, case, apostrophes; words glued or split, never re-cut.
|
||||
return if only_glued_or_split(&a, &b) { EditKind::Typographic } else { EditKind::Substantive };
|
||||
}
|
||||
if a.len() != b.len() {
|
||||
return EditKind::Substantive;
|
||||
}
|
||||
@@ -346,12 +362,36 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accents_case_punctuation_emphasis() {
|
||||
assert_eq!(classify("Je m'engage a verifier.", "Je m’engage à vérifier !"), EditKind::Typographic);
|
||||
assert_eq!(classify("un **être humain**", "un être humain"), EditKind::Typographic);
|
||||
fn accents_case_apostrophes_quotes() {
|
||||
assert_eq!(classify("Je m'engage a verifier.", "Je m’engage à vérifier."), EditKind::Typographic);
|
||||
assert_eq!(classify("le \"Pacte\" de la monnaie", "le « Pacte » de la Monnaie"), EditKind::Typographic);
|
||||
assert_eq!(classify("same", "same"), EditKind::Unchanged);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn punctuation_and_emphasis_go_through_a_proposal() {
|
||||
// Sixth independent review: free punctuation can always carry a meaning.
|
||||
for (old, new) in [
|
||||
("Je m'engage à vérifier.", "Je m'engage à vérifier !"),
|
||||
("Je m'engage à vérifier.", "Je m'engage à vérifier ?"),
|
||||
("un **être humain**", "un être humain"),
|
||||
("tous les **5 jours**", "tous les -**5 jours**"),
|
||||
("Seuil = C + B^W", "Seuil = C + B^-W"),
|
||||
("max(0, T-C)", "max(0, TC)"),
|
||||
("(Y = ceil(N))", "(Y = -ceil(N))"),
|
||||
("- avoir un compte\n- être joignable", "- avoir un compte, être joignable"),
|
||||
("(sauf le cas a) et le cas b", "(sauf le cas a et le cas b)"),
|
||||
("libre - le texte", "libre — le texte"),
|
||||
("- une liste", "* une liste"),
|
||||
("clé DsEx1pS33abc", "clé dSeX1Ps33abc"),
|
||||
("les avoir vérifiés", "le savoir vérifiés"),
|
||||
] {
|
||||
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
|
||||
}
|
||||
assert_eq!(classify("toute latransmission", "toute la transmission"), EditKind::Typographic);
|
||||
assert_eq!(classify("la trans mission", "la transmission"), EditKind::Typographic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_figure_a_sign_or_a_relation_is_never_a_typo() {
|
||||
// Same letters, different meaning (independent reviews, 2026-10-21).
|
||||
@@ -369,13 +409,10 @@ mod tests {
|
||||
] {
|
||||
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
|
||||
}
|
||||
// Punctuation around figures, list markers, dashes between words stay typographic.
|
||||
assert_eq!(classify("50 voix pour", "50 voix pour !"), EditKind::Typographic);
|
||||
assert_eq!(classify("- une liste", "* une liste"), EditKind::Typographic);
|
||||
assert_eq!(classify("50 voix pour", "50 voix Pour"), EditKind::Typographic);
|
||||
// A bracket always counts, even as a list marker: conservative.
|
||||
assert_eq!(classify("1. premier point", "1) premier point"), EditKind::Substantive);
|
||||
assert_eq!(classify("1. premier point", "1. premier point."), EditKind::Typographic);
|
||||
assert_eq!(classify("libre - le texte", "libre — le texte"), EditKind::Typographic);
|
||||
assert_eq!(classify("1. premier point", "1. Premier point"), EditKind::Typographic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user