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/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). diff --git a/plugin/decasify.lua b/plugin/decasify.lua index 5645809..dad362d 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", "fundeu", "grubber", "rae", "tdk" }) else return {} end diff --git a/plugin/decasify.vim b/plugin/decasify.vim index c5916de..5b81612 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', 'fundeu', 'grubber', 'rae', 'tdk'] else let l:candidates = [] endif diff --git a/src/es.rs b/src/es.rs new file mode 100644 index 0000000..7d54170 --- /dev/null +++ b/src/es.rs @@ -0,0 +1,97 @@ +// 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 { + 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_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_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| { + 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, reserved_words) { + true => word.word.to_lowercase(), + false => word.word.to_titlecase_lower_rest(), + } + } + } + }); + chunk.into() +} + +fn is_reserved(word: &Word, reserved_words: &[&str]) -> bool { + 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..b7f16fe 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))] @@ -63,6 +67,7 @@ pub enum Locale { #[default] EN, TR, + ES, } /// Target case selector. @@ -98,6 +103,10 @@ pub enum StyleGuide { LanguageDefault, #[strum(serialize = "tdk")] TurkishLanguageInstitute, + #[strum(serialize = "rae")] + RealAcademiaEspanola, + #[strum(serialize = "fundeu")] + FundeuRealAcademiaEspanola, } #[derive(Clone, Debug, Default, PartialEq)] @@ -180,7 +189,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), - "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()?, } } @@ -266,6 +276,8 @@ 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" | "" => { Ok(StyleGuide::LanguageDefault) diff --git a/tests/lib.rs b/tests/lib.rs index 25e5dde..2a3cffc 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,51 @@ 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!( + 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, @@ -256,6 +311,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 +332,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 +358,6 @@ sentencecase!( "Insert bike here" ); +sentencecase!(sentence_es, Locale::ES, "hola MUNDO", "Hola mundo"); + sentencecase!(sentence_tr, Locale::TR, "ilk DAVRANSIN", "İlk davransın");