From 825fb79b4f328f0918d19b41a5e9b64a07661862 Mon Sep 17 00:00:00 2001 From: Caleb Maclennan Date: Wed, 19 Nov 2025 02:02:49 +0300 Subject: [PATCH 1/6] feat(crate): Implement rudimentry Spanish support following Real Academia Espanola --- Makefile.am | 2 +- plugin/decasify.lua | 4 +-- plugin/decasify.vim | 4 +-- src/es.rs | 84 +++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 5 +++ src/types.rs | 5 +++ 6 files changed, 99 insertions(+), 5 deletions(-) create mode 100644 src/es.rs diff --git a/Makefile.am b/Makefile.am index 461cbbd..c64f2b5 100644 --- a/Makefile.am +++ b/Makefile.am @@ -12,7 +12,7 @@ licensedir = $(datarootdir)/licenses/$(TRANSFORMED_PACKAGE_NAME) bin_PROGRAMS = decasify decasify_SOURCES = src/bin/decasify.rs src/content.rs src/cli.rs src/lib.rs src/types.rs src/traits.rs decasify_SOURCES += src/lua.rs src/python.rs src/wasm.rs -decasify_SOURCES += src/en.rs src/tr.rs +decasify_SOURCES += src/en.rs src/es.rs src/tr.rs EXTRA_decasify_SOURCES = tests/cli.rs tests/lib.rs EXTRA_DIST = pyproject.toml spec/decasify_spec.lua tests/test_all.py plugin/decasify.lua sile/packages/decasify.lua dist_doc_DATA = README.md CHANGELOG.md diff --git a/plugin/decasify.lua b/plugin/decasify.lua index 5645809..0b63b51 100644 --- a/plugin/decasify.lua +++ b/plugin/decasify.lua @@ -79,9 +79,9 @@ end, { if arg_index == 1 then return filter({ "lower", "sentence", "title", "upper" }) elseif arg_index == 2 then - return filter({ "en", "tr" }) + return filter({ "en", "es", "tr" }) elseif arg_index == 3 then - return filter({ "ap", "cmos", "default", "grubber", "tdk" }) + return filter({ "ap", "cmos", "default", "grubber", "rae", "tdk" }) else return {} end diff --git a/plugin/decasify.vim b/plugin/decasify.vim index c5916de..0a7a75d 100644 --- a/plugin/decasify.vim +++ b/plugin/decasify.vim @@ -58,9 +58,9 @@ function! s:DecasifyComplete(arg_lead, cmd_line, _) abort if l:arg_index == 1 let l:candidates = ['lower', 'sentence', 'title', 'upper'] elseif l:arg_index == 2 - let l:candidates = ['en', 'tr'] + let l:candidates = ['en', 'es', 'tr'] elseif l:arg_index == 3 - let l:candidates = ['ap', 'cmos', 'default', 'grubber', 'tdk'] + let l:candidates = ['ap', 'cmos', 'default', 'grubber', 'rae', 'tdk'] else let l:candidates = [] endif diff --git a/src/es.rs b/src/es.rs new file mode 100644 index 0000000..2c66c7d --- /dev/null +++ b/src/es.rs @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: © 2023 Caleb Maclennan +// SPDX-License-Identifier: LGPL-3.0-only + +use crate::content::{Chunk, Segment}; +use crate::get_override; +use crate::types::{StyleGuide, StyleOptions, Word}; + +use unicode_titlecase::StrTitleCase; + +pub fn titlecase(chunk: Chunk, style: StyleGuide, opts: StyleOptions) -> String { + match style { + StyleGuide::LanguageDefault => titlecase_rae(chunk, opts), + StyleGuide::RealAcademiaEspanola => titlecase_rae(chunk, opts), + _ => todo!("Spanish implementation doesn't support this style guide."), + } +} + +fn titlecase_rae(chunk: Chunk, opts: StyleOptions) -> String { + let mut chunk = chunk.clone(); + let mut done_first = false; + chunk.segments.iter_mut().for_each(|segment| { + if let Segment::Word(word) = segment { + word.word = + if let Some(word) = get_override(word, &opts.overrides, |w| w.to_lowercase()) { + word.to_string() + } else if !done_first { + done_first = true; + word.to_titlecase_lower_rest() + } else { + match is_reserved(word) { + true => word.word.to_lowercase(), + false => word.word.to_titlecase_lower_rest(), + } + } + } + }); + chunk.into() +} + +fn is_reserved(word: &Word) -> bool { + let reserved_words = [ + "a", "al", "ante", "bajo", "con", "contra", "de", "del", "desde", "durante", "e", "el", + "en", "entre", "hacia", "hasta", "la", "las", "los", "mas", "mediante", "ni", "o", "para", + "pero", "por", "que", "según", "si", "sin", "so", "sino", "sobre", "tras", "u", "un", + "una", "unas", "unos", "y", + ]; + reserved_words.contains(&word.word.to_lowercase().as_str()) +} + +pub fn lowercase(chunk: Chunk) -> String { + let mut chunk = chunk.clone(); + chunk.segments.iter_mut().for_each(|segment| { + if let Segment::Word(word) = segment { + word.word = word.to_lowercase() + } + }); + chunk.into() +} + +pub fn uppercase(chunk: Chunk) -> String { + let mut chunk = chunk.clone(); + chunk.segments.iter_mut().for_each(|segment| { + if let Segment::Word(word) = segment { + word.word = word.to_uppercase() + } + }); + chunk.into() +} + +pub fn sentencecase(chunk: Chunk) -> String { + let mut chunk = chunk.clone(); + let mut done_first = false; + chunk.segments.iter_mut().for_each(|segment| { + if let Segment::Word(word) = segment { + word.word = if !done_first { + done_first = true; + word.to_titlecase_lower_rest() + } else { + word.to_lowercase() + } + } + }); + chunk.into() +} diff --git a/src/lib.rs b/src/lib.rs index 268ad73..f6cb3d2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,6 +31,7 @@ pub mod python; pub mod wasm; mod en; +mod es; mod tr; /// Convert a string to a specific case following typesetting conventions for a target locale @@ -85,6 +86,7 @@ where let opts: StyleOptions = opts.try_into()?; Ok(match locale { Locale::EN => en::titlecase(chunk, style, opts), + Locale::ES => es::titlecase(chunk, style, opts), Locale::TR => tr::titlecase(chunk, style, opts), }) } @@ -99,6 +101,7 @@ where let locale: Locale = locale.try_into()?; Ok(match locale { Locale::EN => en::lowercase(chunk), + Locale::ES => es::lowercase(chunk), Locale::TR => tr::lowercase(chunk), }) } @@ -113,6 +116,7 @@ where let locale: Locale = locale.try_into()?; Ok(match locale { Locale::EN => en::uppercase(chunk), + Locale::ES => es::uppercase(chunk), Locale::TR => tr::uppercase(chunk), }) } @@ -127,6 +131,7 @@ where let locale: Locale = locale.try_into()?; Ok(match locale { Locale::EN => en::sentencecase(chunk), + Locale::ES => es::sentencecase(chunk), Locale::TR => tr::sentencecase(chunk), }) } diff --git a/src/types.rs b/src/types.rs index 309bfd0..31d7388 100644 --- a/src/types.rs +++ b/src/types.rs @@ -62,6 +62,7 @@ pub struct Word { pub enum Locale { #[default] EN, + ES, TR, } @@ -96,6 +97,8 @@ pub enum StyleGuide { #[strum(serialize = "default")] #[default] LanguageDefault, + #[strum(serialize = "rae")] + RealAcademiaEspanola, #[strum(serialize = "tdk")] TurkishLanguageInstitute, } @@ -180,6 +183,7 @@ impl FromStr for Locale { fn from_str(s: &str) -> Result { match s.to_ascii_lowercase().as_str() { "en" | "english" | "en_en" => Ok(Locale::EN), + "es" | "spanish" | "es_es" | "español" => Ok(Locale::ES), "tr" | "turkish" | "tr_tr" | "türkçe" => Ok(Locale::TR), input => LocaleSnafu { input }.fail()?, } @@ -266,6 +270,7 @@ impl FromStr for StyleGuide { "daringfireball" | "gruber" | "fireball" => Ok(StyleGuide::DaringFireball), "associatedpress" | "ap" => Ok(StyleGuide::AssociatedPress), "chicagoManualofstyle" | "chicago" | "cmos" => Ok(StyleGuide::ChicagoManualOfStyle), + "rae" | "realacademiaespanola" => Ok(StyleGuide::RealAcademiaEspanola), "tdk" | "turkishlanguageinstitute" => Ok(StyleGuide::TurkishLanguageInstitute), "default" | "languagedefault" | "language" | "none" | "" => { Ok(StyleGuide::LanguageDefault) From ceb3b1b1c44cb660d76d15a489595f3597d5688c Mon Sep 17 00:00:00 2001 From: Caleb Maclennan Date: Wed, 19 Nov 2025 02:17:20 +0300 Subject: [PATCH 2/6] test(crate): Add Spanish tests --- tests/lib.rs | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/lib.rs b/tests/lib.rs index 25e5dde..19d86bd 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -90,6 +90,16 @@ case!( " Foo Bar " ); +case!( + trivia_es, + Case::Title, + Locale::ES, + StyleGuide::LanguageDefault, + StyleOptions::default(), + " foo bar ", + " Foo Bar " +); + case!( trivia_tr, Case::Title, @@ -199,6 +209,42 @@ titlecase!( " Free Trolling\n Space " ); +titlecase!( + rae_articles, + Locale::ES, + StyleGuide::LanguageDefault, + StyleOptions::default(), + "el libro del autor", + "El Libro del Autor" +); + +titlecase!( + rae_holiday, + Locale::ES, + StyleGuide::RealAcademiaEspanola, + StyleOptions::default(), + "DÍA DE los muertos", + "Día de los Muertos" +); + +titlecase!( + rae_magazine, + Locale::ES, + StyleGuide::RealAcademiaEspanola, + StyleOptions::default(), + "cien años DE soledad", + "Cien Años de Soledad" +); + +titlecase!( + rae_prepositions, + Locale::ES, + StyleGuide::LanguageDefault, + StyleOptions::default(), + "en la casa de mi madre", + "En la Casa de Mi Madre" +); + titlecase!( turkish_question, Locale::TR, @@ -256,6 +302,8 @@ macro_rules! lowercase { lowercase!(lower_en, Locale::EN, "foo BAR BaZ BIKE", "foo bar baz bike"); +lowercase!(lower_es, Locale::ES, "Hola MUNDO", "hola mundo"); + lowercase!( lower_tr, Locale::TR, @@ -275,6 +323,8 @@ macro_rules! uppercase { uppercase!(upper_en, Locale::EN, "foo BAR BaZ bike", "FOO BAR BAZ BIKE"); +uppercase!(upper_es, Locale::ES, "hola MUNDo", "HOLA MUNDO"); + uppercase!( upper_tr, Locale::TR, @@ -299,4 +349,6 @@ sentencecase!( "Insert bike here" ); +sentencecase!(sentence_es, Locale::ES, "hola MUNDO", "Hola mundo"); + sentencecase!(sentence_tr, Locale::TR, "ilk DAVRANSIN", "İlk davransın"); From 365bc5d94748ce029a34fa258d4ec8f332ed484b Mon Sep 17 00:00:00 2001 From: Caleb Maclennan Date: Wed, 19 Nov 2025 14:33:33 +0300 Subject: [PATCH 3/6] docs(readme): Mention Spanish support and caveat the title casing --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 30f585e..5629bcb 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,10 @@ The CLI defaults to titlecase and English, but lower, upper, and sentence case o The Rust, Lua, Python, and JavaScript library APIs have functions specific to each operation. Where possible the APIs currently default to English rules and (for English) the Gruber style guide, but others are available. +The Spanish style roughly follows [optional stylistic exceptions](https://www.rae.es/dpd/may%C3%BAsculas) noted by Real Academia Española. +Keep in mind most Spanish style guides eschew title casing and use sentence-case for many things that would traditionally be title-cased in English. +This library implements a best-guess at title-casing when asked to, it does not help you understand when (not) to use it in the first place. + The Turkish style follows the Turkish Language Institute's [guidelines][tdk]. For English, three style guides are known: Associated Press (AP), Chicago Manual of Style (CMOS), and John Gruber's Daring Fireball (Gruber). From bb06bedeb50144be47ca0668fd8a4be5a9256ba3 Mon Sep 17 00:00:00 2001 From: Caleb Maclennan Date: Wed, 19 Nov 2025 14:39:42 +0300 Subject: [PATCH 4/6] feat(crate): Enable recognition of ASCII variants of localized language names --- src/types.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/types.rs b/src/types.rs index 31d7388..60ad082 100644 --- a/src/types.rs +++ b/src/types.rs @@ -183,8 +183,8 @@ impl FromStr for Locale { fn from_str(s: &str) -> Result { match s.to_ascii_lowercase().as_str() { "en" | "english" | "en_en" => Ok(Locale::EN), - "es" | "spanish" | "es_es" | "español" => Ok(Locale::ES), - "tr" | "turkish" | "tr_tr" | "türkçe" => Ok(Locale::TR), + "es" | "spanish" | "es_es" | "espanol" | "español" => Ok(Locale::ES), + "tr" | "turkish" | "tr_tr" | "turkce" | "türkçe" => Ok(Locale::TR), input => LocaleSnafu { input }.fail()?, } } From 80fead914513e71f0b79f686049923f643cfbd9a Mon Sep 17 00:00:00 2001 From: Caleb Maclennan Date: Wed, 19 Nov 2025 23:12:24 +0300 Subject: [PATCH 5/6] chore(crate): Fix enum variant order to not break semver compatibility --- src/types.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/types.rs b/src/types.rs index 60ad082..6a29308 100644 --- a/src/types.rs +++ b/src/types.rs @@ -53,6 +53,10 @@ pub struct Word { pub word: String, } +// WARNING: These enums can't change order when adding new variants because some modules cast them +// to integers, and that would make for ABI breakage. The variants can be re-sorted (alphabetically +// or logically or whatever) when a major version with no ABI compatibility guarantees is okay. + /// Locale selector to change language support rules of case functions. #[derive(Default, Display, VariantNames, Debug, Clone, Copy, PartialEq)] #[cfg_attr(feature = "pythonmodule", pyclass(eq))] @@ -62,8 +66,8 @@ pub struct Word { pub enum Locale { #[default] EN, - ES, TR, + ES, } /// Target case selector. @@ -97,10 +101,10 @@ pub enum StyleGuide { #[strum(serialize = "default")] #[default] LanguageDefault, - #[strum(serialize = "rae")] - RealAcademiaEspanola, #[strum(serialize = "tdk")] TurkishLanguageInstitute, + #[strum(serialize = "rae")] + RealAcademiaEspanola, } #[derive(Clone, Debug, Default, PartialEq)] From 88abf7748c887830407ca7b7bffca901eec4cb31 Mon Sep 17 00:00:00 2001 From: Caleb Maclennan Date: Thu, 20 Nov 2025 15:09:49 +0300 Subject: [PATCH 6/6] =?UTF-8?q?feat(crate):=20Add=20second=20style=20guide?= =?UTF-8?q?=20for=20Spanish=20based=20on=20Fund=C3=A9uRAE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Adrián Berges --- plugin/decasify.lua | 2 +- plugin/decasify.vim | 2 +- src/es.rs | 35 ++++++++++++++++++++++++----------- src/types.rs | 3 +++ tests/lib.rs | 9 +++++++++ 5 files changed, 38 insertions(+), 13 deletions(-) diff --git a/plugin/decasify.lua b/plugin/decasify.lua index 0b63b51..dad362d 100644 --- a/plugin/decasify.lua +++ b/plugin/decasify.lua @@ -81,7 +81,7 @@ end, { elseif arg_index == 2 then return filter({ "en", "es", "tr" }) elseif arg_index == 3 then - return filter({ "ap", "cmos", "default", "grubber", "rae", "tdk" }) + return filter({ "ap", "cmos", "default", "fundeu", "grubber", "rae", "tdk" }) else return {} end diff --git a/plugin/decasify.vim b/plugin/decasify.vim index 0a7a75d..5b81612 100644 --- a/plugin/decasify.vim +++ b/plugin/decasify.vim @@ -60,7 +60,7 @@ function! s:DecasifyComplete(arg_lead, cmd_line, _) abort elseif l:arg_index == 2 let l:candidates = ['en', 'es', 'tr'] elseif l:arg_index == 3 - let l:candidates = ['ap', 'cmos', 'default', 'grubber', 'rae', 'tdk'] + let l:candidates = ['ap', 'cmos', 'default', 'fundeu', 'grubber', 'rae', 'tdk'] else let l:candidates = [] endif diff --git a/src/es.rs b/src/es.rs index 2c66c7d..7d54170 100644 --- a/src/es.rs +++ b/src/es.rs @@ -8,14 +8,33 @@ use crate::types::{StyleGuide, StyleOptions, Word}; use unicode_titlecase::StrTitleCase; pub fn titlecase(chunk: Chunk, style: StyleGuide, opts: StyleOptions) -> String { + let articles_prepositions_conjunctions = [ + "a", "al", "ante", "bajo", "con", "contra", "de", "del", "desde", "durante", "e", "el", + "en", "entre", "hacia", "hasta", "la", "las", "los", "mas", "mediante", "ni", "o", "para", + "pero", "por", "que", "según", "si", "sin", "so", "sino", "sobre", "tras", "u", "un", + "una", "unas", "unos", "y", + ]; + let determiners = [ + "mi", "mis", "nuestro", "nuestra", "nuestros", "nuestras", "tu", "tus", "vuestro", + "vuestra", "vuestros", "vuestras", "su", "sus", + ]; match style { - StyleGuide::LanguageDefault => titlecase_rae(chunk, opts), - StyleGuide::RealAcademiaEspanola => titlecase_rae(chunk, opts), + StyleGuide::LanguageDefault => { + titlecase_spanish(chunk, opts, &articles_prepositions_conjunctions) + } + StyleGuide::RealAcademiaEspanola => { + titlecase_spanish(chunk, opts, &articles_prepositions_conjunctions) + } + StyleGuide::FundeuRealAcademiaEspanola => { + let mut combined = articles_prepositions_conjunctions.to_vec(); + combined.extend_from_slice(&determiners); + titlecase_spanish(chunk, opts, &combined) + } _ => todo!("Spanish implementation doesn't support this style guide."), } } -fn titlecase_rae(chunk: Chunk, opts: StyleOptions) -> String { +fn titlecase_spanish(chunk: Chunk, opts: StyleOptions, reserved_words: &[&str]) -> String { let mut chunk = chunk.clone(); let mut done_first = false; chunk.segments.iter_mut().for_each(|segment| { @@ -27,7 +46,7 @@ fn titlecase_rae(chunk: Chunk, opts: StyleOptions) -> String { done_first = true; word.to_titlecase_lower_rest() } else { - match is_reserved(word) { + match is_reserved(word, reserved_words) { true => word.word.to_lowercase(), false => word.word.to_titlecase_lower_rest(), } @@ -37,13 +56,7 @@ fn titlecase_rae(chunk: Chunk, opts: StyleOptions) -> String { chunk.into() } -fn is_reserved(word: &Word) -> bool { - let reserved_words = [ - "a", "al", "ante", "bajo", "con", "contra", "de", "del", "desde", "durante", "e", "el", - "en", "entre", "hacia", "hasta", "la", "las", "los", "mas", "mediante", "ni", "o", "para", - "pero", "por", "que", "según", "si", "sin", "so", "sino", "sobre", "tras", "u", "un", - "una", "unas", "unos", "y", - ]; +fn is_reserved(word: &Word, reserved_words: &[&str]) -> bool { reserved_words.contains(&word.word.to_lowercase().as_str()) } diff --git a/src/types.rs b/src/types.rs index 6a29308..b7f16fe 100644 --- a/src/types.rs +++ b/src/types.rs @@ -105,6 +105,8 @@ pub enum StyleGuide { TurkishLanguageInstitute, #[strum(serialize = "rae")] RealAcademiaEspanola, + #[strum(serialize = "fundeu")] + FundeuRealAcademiaEspanola, } #[derive(Clone, Debug, Default, PartialEq)] @@ -274,6 +276,7 @@ impl FromStr for StyleGuide { "daringfireball" | "gruber" | "fireball" => Ok(StyleGuide::DaringFireball), "associatedpress" | "ap" => Ok(StyleGuide::AssociatedPress), "chicagoManualofstyle" | "chicago" | "cmos" => Ok(StyleGuide::ChicagoManualOfStyle), + "fundeu" | "fundeurealacademiaespanola" => Ok(StyleGuide::FundeuRealAcademiaEspanola), "rae" | "realacademiaespanola" => Ok(StyleGuide::RealAcademiaEspanola), "tdk" | "turkishlanguageinstitute" => Ok(StyleGuide::TurkishLanguageInstitute), "default" | "languagedefault" | "language" | "none" | "" => { diff --git a/tests/lib.rs b/tests/lib.rs index 19d86bd..2a3cffc 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -245,6 +245,15 @@ titlecase!( "En la Casa de Mi Madre" ); +titlecase!( + fundu_prepositions, + Locale::ES, + StyleGuide::FundeuRealAcademiaEspanola, + StyleOptions::default(), + "en la casa de mi madre", + "En la Casa de mi Madre" +); + titlecase!( turkish_question, Locale::TR,