From e21a078b5ab7c66fe7e7fb6657b69a3eba455dec Mon Sep 17 00:00:00 2001 From: Skaywalker <115165402+Skaywalker@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:53:01 +0200 Subject: [PATCH] proof: a one-flag field keeps its first byte, a directive reads only its first argument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without FLAG UTF-8, Hunspell reads a field that names one flag (PFX/SFX, KEEPCASE, FORBIDDENWORD and the like) through decode_flag, which takes its first byte: SFX é is the flag 0xC3. Such a field no longer fails the load. SYLLABLENUM lists flags and is read byte by byte like AF. SET and FLAG read only their first argument, so FLAG UTF-8 with a trailing comment keeps Unicode flags. --- crates/proof/src/hunspell.rs | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/crates/proof/src/hunspell.rs b/crates/proof/src/hunspell.rs index 608e9477..934520be 100644 --- a/crates/proof/src/hunspell.rs +++ b/crates/proof/src/hunspell.rs @@ -228,7 +228,11 @@ pub fn decode(aff: &[u8], dic: &[u8]) -> Result<(String, String), String> { let directive = |name: &str| { head.lines().find_map(|l| { let l = l.trim_start_matches('\u{feff}'); - l.strip_prefix(name).filter(|rest| rest.starts_with([' ', '\t'])).map(|rest| rest.trim().to_ascii_uppercase()) + // Only the first argument counts, as in Hunspell: `FLAG UTF-8 # Unicode flags` is UTF-8. + l.strip_prefix(name) + .filter(|rest| rest.starts_with([' ', '\t'])) + .and_then(|rest| rest.split([' ', '\t']).find(|f| !f.is_empty())) + .map(str::to_ascii_uppercase) }) }; let set = directive("SET").unwrap_or_else(|| "UTF-8".into()); @@ -365,9 +369,12 @@ fn decode_utf8_aff(aff: &[u8]) -> Result { let in_comment = comment_at.is_some_and(|c| words.get(c).is_some_and(|&w| i >= w)); let decoded = match (dir.as_str(), field) { _ if in_comment => String::from_utf8_lossy(p).into_owned(), - ("AF" | "PFX" | "SFX" | "COMPOUNDRULE", Some(1)) => bytes_as_flags(p), + ("AF" | "COMPOUNDRULE" | "SYLLABLENUM", Some(1)) => bytes_as_flags(p), ("PFX" | "SFX", Some(3)) | ("CHECKCOMPOUNDPATTERN", Some(1 | 2)) => text_and_flags(p, &what)?, - (d, Some(1)) if FLAG_DIRECTIVES.contains(&d) => bytes_as_flags(p), + // A field that names one flag keeps its first byte, as Hunspell's `decode_flag` + // does: `SFX é` is the flag 0xC3, the first of the two flags `kert/é` carries. + ("PFX" | "SFX", Some(1)) => bytes_as_flags(p.get(..1).unwrap_or_default()), + (d, Some(1)) if FLAG_DIRECTIVES.contains(&d) => bytes_as_flags(p.get(..1).unwrap_or_default()), _ => std::str::from_utf8(p).map_err(|e| format!("{what}: not UTF-8: {e}"))?.to_string(), }; out.push_str(&decoded); @@ -501,6 +508,28 @@ mod tests { assert!(decode(b"SET UTF-8\n", b"1\nh\xe1z/a\n").is_err(), "a stem must be UTF-8"); } + #[test] + fn single_flag_fields_keep_their_first_byte() { + // Without `FLAG UTF-8`, a UTF-8 `é` (0xC3 0xA9) in a field that names one flag is the + // flag 0xC3; in a `.dic` flag list it is the two flags 0xC3 and 0xA9. + let aff = "SET UTF-8\nKEEPCASE é\nSFX é Y 1\nSFX é 0 ok .\nSYLLABLENUM é\n".as_bytes(); + let (a, d) = decode(aff, "2\nkert/é\nNATO/é\n".as_bytes()).unwrap(); + assert!(a.contains("KEEPCASE \u{c3}\n") && a.contains("SFX \u{c3} Y 1") && a.contains("SFX \u{c3} 0 ok ."), "{a}"); + assert!(a.contains("SYLLABLENUM \u{c3}\u{a9}"), "SYLLABLENUM lists flags: {a}"); + assert!(d.contains("kert/\u{c3}\u{a9}")); + let dict = crate::affix::AffixDict::from_hunspell(&a, &d, &[]).unwrap(); + assert!(dict.check("kert") && dict.check("kertok")); + assert!(dict.check("NATO") && !dict.check("Nato"), "KEEPCASE é reaches NATO/é"); + } + + #[test] + fn a_comment_after_flag_utf8_keeps_unicode_flags() { + let (_, d) = decode(b"SET UTF-8\nFLAG UTF-8 # Unicode flags\n", "kert/\u{e9}\n".as_bytes()).unwrap(); + assert_eq!(d, "kert/\u{e9}\n"); + let (_, d) = decode(b"SET\tUTF-8 # comment\nFLAG\t\tUTF-8\n", "kert/\u{e9}\n".as_bytes()).unwrap(); + assert_eq!(d, "kert/\u{e9}\n", "repeated spaces and tabs separate the argument"); + } + #[test] fn decodes_russian_single_byte_encodings() { // `ёлка` in KOI8-R (0xA3 0xCC 0xCB 0xC1) and in Windows-1251 (0xB8 0xEB 0xEA 0xE0).