From f0c7f0993059bcab479162defe381bb0ce7686cd Mon Sep 17 00:00:00 2001 From: Caleb Maclennan Date: Thu, 13 Nov 2025 12:49:52 +0300 Subject: [PATCH 1/8] refactor(crate): Unify error types so we don't have to convert between them --- src/bin/decasify.rs | 46 ++++++++++++--------------------------------- src/content.rs | 20 +------------------- src/lib.rs | 3 ++- src/types.rs | 19 ++++++++++--------- tests/lib.rs | 2 +- typst/src/lib.rs | 3 ++- 6 files changed, 28 insertions(+), 65 deletions(-) diff --git a/src/bin/decasify.rs b/src/bin/decasify.rs index 8a7e8af..78815ee 100644 --- a/src/bin/decasify.rs +++ b/src/bin/decasify.rs @@ -1,53 +1,31 @@ // SPDX-FileCopyrightText: © 2023 Caleb Maclennan // SPDX-License-Identifier: LGPL-3.0-only -use decasify::cli::Cli; -use decasify::{lowercase, sentencecase, titlecase, uppercase}; -use decasify::{Case, Locale, StyleGuide, StyleOptions, StyleOptionsBuilder}; - -use snafu::prelude::*; - use clap::CommandFactory; use std::io; use std::io::BufRead; -#[derive(Snafu)] -enum Error { - #[snafu(display("Failed to identify input"))] - Input {}, - - #[snafu(display("Failed to resolve a known locale"))] - Locale {}, - - #[snafu(display("Failed to resolve a known case"))] - Case {}, - - #[snafu(display("Failed to resolve a known style guide"))] - StyleGuide {}, -} - -// Clap CLI errors are reported using the Debug trait, but Snafu sets up the Display trait. -// So we delegate. c.f. https://github.com/shepmaster/snafu/issues/110 -impl std::fmt::Debug for Error { - fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { - std::fmt::Display::fmt(self, fmt) - } -} - -type Result = std::result::Result; +use decasify::cli::Cli; +use decasify::types::Result; +use decasify::{lowercase, sentencecase, titlecase, uppercase}; +use decasify::{Case, Locale, StyleGuide, StyleOptions, StyleOptionsBuilder}; fn main() -> Result<()> { let version = option_env!("VERGEN_GIT_DESCRIBE").unwrap_or_else(|| env!("CARGO_PKG_VERSION")); let app = Cli::command().version(version); let matches = app.get_matches(); - let locale = matches.get_one::("locale").context(LocaleSnafu)?; let case = matches .get_one::("case") - .context(CaseSnafu)? + .unwrap_or(&Case::default()) + .to_owned(); + eprintln! {"case: {case:?}"}; + let locale = matches + .get_one::("locale") + .unwrap_or(&Locale::default()) .to_owned(); let style = matches .get_one::("style") - .context(StyleGuideSnafu)? + .unwrap_or(&StyleGuide::default()) .to_owned(); let opts = if let Some(overrides) = matches.get_many::("overrides") { StyleOptionsBuilder::new() @@ -60,7 +38,7 @@ fn main() -> Result<()> { true => { let input: Vec = matches .get_many::("input") - .context(InputSnafu)? + .unwrap() .cloned() .collect(); let input: Vec = vec![input.join(" ")]; diff --git a/src/content.rs b/src/content.rs index 394f0d5..0f63f61 100644 --- a/src/content.rs +++ b/src/content.rs @@ -1,13 +1,11 @@ // SPDX-FileCopyrightText: © 2023 Caleb Maclennan // SPDX-License-Identifier: LGPL-3.0-only -pub use crate::types::Word; - use regex::Regex; use std::{borrow::Cow, fmt, fmt::Display, str::FromStr}; use unicode_titlecase::StrTitleCase; -use snafu::prelude::*; +use crate::types::{Error, Result, Word}; #[derive(Clone, Debug)] #[non_exhaustive] @@ -22,22 +20,6 @@ pub enum Segment { Word(Word), } -#[derive(Snafu)] -pub enum Error { - #[snafu(display("Unable to cast str to Chunk"))] - StrToChunk {}, -} - -// Clap CLI errors are reported using the Debug trait, but Snafu sets up the Display trait. -// So we delegate. c.f. https://github.com/shepmaster/snafu/issues/110 -impl std::fmt::Debug for Error { - fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { - std::fmt::Display::fmt(self, fmt) - } -} - -pub type Result = std::result::Result; - fn split_chunk(s: &str) -> Chunk { let mut segments: Vec = Vec::new(); let captures = Regex::new(r"(?\p{Whitespace}+)|(?\P{Whitespace}+)").unwrap(); diff --git a/src/lib.rs b/src/lib.rs index b84e470..b441dfc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,12 +6,13 @@ mod content; mod traits; -mod types; +pub mod types; pub use content::Chunk; #[cfg(feature = "unstable-trait")] pub use traits::Decasify; pub use types::{Case, Locale, StyleGuide, StyleOptions, StyleOptionsBuilder, Word}; +pub use types::{Error, Result}; #[cfg(feature = "cli")] #[doc(hidden)] diff --git a/src/types.rs b/src/types.rs index bf5098f..79bd097 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,11 +1,11 @@ // SPDX-FileCopyrightText: © 2023 Caleb Maclennan // SPDX-License-Identifier: LGPL-3.0-only +use snafu::prelude::*; +use std::fmt::{Debug, Display, Formatter}; use std::str::FromStr; use strum_macros::{Display, VariantNames}; -use snafu::prelude::*; - #[cfg(feature = "pythonmodule")] use pyo3::prelude::*; @@ -13,25 +13,26 @@ use pyo3::prelude::*; use wasm_bindgen::prelude::*; #[derive(Snafu)] +#[snafu(visibility(pub))] pub enum Error { - #[snafu(display("Invalid input language {}", input))] + #[snafu(display("Invalid input language '{input}'"))] Locale { input: String }, - #[snafu(display("Invalid target case {}", input))] + #[snafu(display("Invalid target case '{input}'"))] Case { input: String }, - #[snafu(display("Invalid preferred style guide {}", input))] + #[snafu(display("Invalid preferred style guide '{input}'"))] StyleGuide { input: String }, - #[snafu(display("Invalid style options {}", input))] + #[snafu(display("Invalid style options '{input}'"))] StyleOptions { input: String }, } // Clap CLI errors are reported using the Debug trait, but Snafu sets up the Display trait. // So we delegate. c.f. https://github.com/shepmaster/snafu/issues/110 -impl std::fmt::Debug for Error { - fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { - std::fmt::Display::fmt(self, fmt) +impl Debug for Error { + fn fmt(&self, fmt: &mut Formatter) -> std::fmt::Result { + Display::fmt(self, fmt) } } diff --git a/tests/lib.rs b/tests/lib.rs index 23711e3..0d89a21 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -36,7 +36,7 @@ fn custom_style_guide() { #[cfg(feature = "unstable-trait")] #[test] fn trait_chery() { - use decasify::Decasify; + use Decasify::Decasify; let s = "WHY THE LONG FACE?"; assert_eq!(s.to_case("sentence", "en", None), "Why the long face?"); assert_eq!( diff --git a/typst/src/lib.rs b/typst/src/lib.rs index f867a02..de42c40 100644 --- a/typst/src/lib.rs +++ b/typst/src/lib.rs @@ -2,9 +2,10 @@ // SPDX-License-Identifier: LGPL-3.0-only use anyhow::Result; -use decasify::{Case, Locale, StyleGuide, StyleOptions, StyleOptionsBuilder}; use wasm_minimal_protocol::{initiate_protocol, wasm_func}; +use decasify::{Case, Locale, StyleGuide, StyleOptions, StyleOptionsBuilder}; + initiate_protocol!(); #[wasm_func] From 315635793ebe4e4ff1fa383ee21a9ada37c5d723 Mon Sep 17 00:00:00 2001 From: Caleb Maclennan Date: Wed, 12 Nov 2025 16:31:30 -0700 Subject: [PATCH 2/8] refactor(crate)!: Implement TryFrom instead of From to avoid panics BREAKING CHANGE: The infalliable conversion functions are now mostly falliable. This allows them to be used more robustly when the inputs might not be know (e.g. the language identifier string hasn't been validated or input has invalid UTF-8 encoding). The catch is that it makes most function return a `Result` instead of `String`. --- README.md | 4 +- src/bin/decasify.rs | 38 ++++++++---------- src/lib.rs | 94 ++++++++++++++++++++++++++++++--------------- src/types.rs | 83 +++++++++++++++++++++++---------------- tests/lib.rs | 23 +++++------ 5 files changed, 143 insertions(+), 99 deletions(-) diff --git a/README.md b/README.md index 25b23b1..1300e2b 100644 --- a/README.md +++ b/README.md @@ -103,10 +103,10 @@ use decasify::{Locale, StyleGuide, StyleOptions}; fn demo() { let input = "ILIK SU VE İTEN RÜZGARLAR"; - let output = titlecase(input, Locale::TR, StyleGuide::LanguageDefault, StyleOptions::default()); + let output = titlecase(input, Locale::TR, StyleGuide::LanguageDefault, StyleOptions::default()).unwrap(); eprintln! {"{output}"}; let input = "title with a twist: a colon"; - let output = titlecase(input, Locale::EN, StyleGuide::DaringFireball, StyleOptions::default()); + let output = titlecase(input, Locale::EN, StyleGuide::DaringFireball, StyleOptions::default()).unwrap(); eprintln! {"{output}"}; } ``` diff --git a/src/bin/decasify.rs b/src/bin/decasify.rs index 78815ee..05fa576 100644 --- a/src/bin/decasify.rs +++ b/src/bin/decasify.rs @@ -14,15 +14,14 @@ fn main() -> Result<()> { let version = option_env!("VERGEN_GIT_DESCRIBE").unwrap_or_else(|| env!("CARGO_PKG_VERSION")); let app = Cli::command().version(version); let matches = app.get_matches(); - let case = matches - .get_one::("case") - .unwrap_or(&Case::default()) - .to_owned(); - eprintln! {"case: {case:?}"}; let locale = matches .get_one::("locale") .unwrap_or(&Locale::default()) .to_owned(); + let case = matches + .get_one::("case") + .unwrap_or(&Case::default()) + .to_owned(); let style = matches .get_one::("style") .unwrap_or(&StyleGuide::default()) @@ -44,21 +43,17 @@ fn main() -> Result<()> { let input: Vec = vec![input.join(" ")]; process( input.iter().map(|ln| ln.to_string()), - *locale, + locale, case, style, opts, - ); + ) + } + false => { + let stdin = io::stdin().lock().lines().map(|ln| ln.unwrap()); + process(stdin, locale, case, style, opts) } - false => process( - io::stdin().lock().lines().map(|ln| ln.unwrap()), - *locale, - case, - style, - opts, - ), } - Ok(()) } fn process>( @@ -67,15 +62,16 @@ fn process>( case: Case, style: StyleGuide, opts: StyleOptions, -) { +) -> Result<()> { for string in strings { let output = match case { - Case::Title => titlecase(string, locale, style.clone(), opts.clone()), - Case::Lower => lowercase(string, locale), - Case::Upper => uppercase(string, locale), - Case::Sentence => sentencecase(string, locale), + Case::Title => titlecase(string, locale, style.clone(), opts.clone())?, + Case::Lower => lowercase(string, locale)?, + Case::Upper => uppercase(string, locale)?, + Case::Sentence => sentencecase(string, locale)?, _ => unreachable!(), }; - println!("{output}") + println!("{output}"); } + Ok(()) } diff --git a/src/lib.rs b/src/lib.rs index b441dfc..268ad73 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,18 +34,28 @@ mod en; mod tr; /// Convert a string to a specific case following typesetting conventions for a target locale -pub fn case( +pub fn case( chunk: impl Into, - case: impl Into, - locale: impl Into, - style: impl Into, - opts: impl Into, -) -> String { + case: TC, + locale: TL, + style: TS, + opts: TO, +) -> Result +where + TC: TryInto, + TL: TryInto, + TS: TryInto, + TO: TryInto, + Error: From, + Error: From, + Error: From, + Error: From, +{ let chunk: Chunk = chunk.into(); - let case: Case = case.into(); - let locale: Locale = locale.into(); - let style: StyleGuide = style.into(); - let opts: StyleOptions = opts.into(); + let case: Case = case.try_into()?; + let locale: Locale = locale.try_into()?; + let style: StyleGuide = style.try_into()?; + let opts: StyleOptions = opts.try_into()?; match case { Case::Lower => lowercase(chunk, locale), Case::Upper => uppercase(chunk, locale), @@ -55,50 +65,70 @@ pub fn case( } /// Convert a string to title case following typesetting conventions for a target locale -pub fn titlecase( +pub fn titlecase( chunk: impl Into, - locale: impl Into, - style: impl Into, - opts: impl Into, -) -> String { + locale: TL, + style: TS, + opts: TO, +) -> Result +where + TL: TryInto, + TS: TryInto, + TO: TryInto, + Error: From, + Error: From, + Error: From, +{ let chunk: Chunk = chunk.into(); - let locale: Locale = locale.into(); - let style: StyleGuide = style.into(); - let opts: StyleOptions = opts.into(); - match locale { + let locale: Locale = locale.try_into()?; + let style: StyleGuide = style.try_into()?; + let opts: StyleOptions = opts.try_into()?; + Ok(match locale { Locale::EN => en::titlecase(chunk, style, opts), Locale::TR => tr::titlecase(chunk, style, opts), - } + }) } /// Convert a string to lower case following typesetting conventions for a target locale -pub fn lowercase(chunk: impl Into, locale: impl Into) -> String { +pub fn lowercase(chunk: impl Into, locale: TL) -> Result +where + TL: TryInto, + Error: From, +{ let chunk: Chunk = chunk.into(); - let locale: Locale = locale.into(); - match locale { + let locale: Locale = locale.try_into()?; + Ok(match locale { Locale::EN => en::lowercase(chunk), Locale::TR => tr::lowercase(chunk), - } + }) } /// Convert a string to upper case following typesetting conventions for a target locale -pub fn uppercase(chunk: impl Into, locale: impl Into) -> String { +pub fn uppercase(chunk: impl Into, locale: TL) -> Result +where + TL: TryInto, + Error: From, +{ let chunk: Chunk = chunk.into(); - let locale: Locale = locale.into(); - match locale { + let locale: Locale = locale.try_into()?; + Ok(match locale { Locale::EN => en::uppercase(chunk), Locale::TR => tr::uppercase(chunk), - } + }) } /// Convert a string to sentence case following typesetting conventions for a target locale -pub fn sentencecase(chunk: impl Into, locale: impl Into) -> String { +pub fn sentencecase(chunk: impl Into, locale: TL) -> Result +where + TL: TryInto, + Error: From, +{ let chunk: Chunk = chunk.into(); - let locale: Locale = locale.into(); - match locale { + let locale: Locale = locale.try_into()?; + Ok(match locale { Locale::EN => en::sentencecase(chunk), Locale::TR => tr::sentencecase(chunk), - } + }) } fn get_override(word: &Word, overrides: &Option>, case_fn: F) -> Option diff --git a/src/types.rs b/src/types.rs index 79bd097..a06d61f 100644 --- a/src/types.rs +++ b/src/types.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: LGPL-3.0-only use snafu::prelude::*; +use std::convert::{Infallible, TryFrom}; use std::fmt::{Debug, Display, Formatter}; use std::str::FromStr; use strum_macros::{Display, VariantNames}; @@ -36,6 +37,12 @@ impl Debug for Error { } } +impl From for Error { + fn from(_: Infallible) -> Self { + unreachable!() + } +} + pub type Result = std::result::Result; /// Just a single word @@ -100,12 +107,6 @@ pub struct StyleOptions { pub overrides: Option>, } -impl From<&str> for StyleOptions { - fn from(s: &str) -> Self { - Self::from_str(s).unwrap() - } -} - impl FromStr for StyleOptions { type Err = Error; fn from_str(s: &str) -> Result { @@ -116,6 +117,13 @@ impl FromStr for StyleOptions { } } +impl TryFrom<&str> for StyleOptions { + type Error = Error; + fn try_from(s: &str) -> Result { + Self::from_str(s) + } +} + #[derive(Debug)] pub struct StyleOptionsBuilder { overrides: Option>, @@ -156,21 +164,24 @@ impl FromStr for Locale { } } -impl From<&str> for Locale { - fn from(s: &str) -> Self { - Self::from_str(s).unwrap() +impl TryFrom<&str> for Locale { + type Error = Error; + fn try_from(s: &str) -> Result { + Self::from_str(s) } } -impl From for Locale { - fn from(s: String) -> Self { - Self::from_str(s.as_ref()).unwrap() +impl TryFrom for Locale { + type Error = Error; + fn try_from(s: String) -> Result { + Self::from_str(&s) } } -impl From<&String> for Locale { - fn from(s: &String) -> Self { - Self::from_str(s.as_ref()).unwrap() +impl TryFrom<&String> for Locale { + type Error = Error; + fn try_from(s: &String) -> Result { + Self::from_str(s) } } @@ -194,21 +205,24 @@ impl FromStr for Case { } } -impl From<&str> for Case { - fn from(s: &str) -> Self { - Self::from_str(s).unwrap() +impl TryFrom<&str> for Case { + type Error = Error; + fn try_from(s: &str) -> Result { + Self::from_str(s) } } -impl From for Case { - fn from(s: String) -> Self { - Self::from_str(s.as_ref()).unwrap() +impl TryFrom for Case { + type Error = Error; + fn try_from(s: String) -> Result { + Self::from_str(&s) } } -impl From<&String> for Case { - fn from(s: &String) -> Self { - Self::from_str(s.as_ref()).unwrap() +impl TryFrom<&String> for Case { + type Error = Error; + fn try_from(s: &String) -> Result { + Self::from_str(s) } } @@ -235,21 +249,24 @@ impl FromStr for StyleGuide { } } -impl From<&str> for StyleGuide { - fn from(s: &str) -> Self { - Self::from_str(s).unwrap() +impl TryFrom<&str> for StyleGuide { + type Error = Error; + fn try_from(s: &str) -> Result { + Self::from_str(s) } } -impl From for StyleGuide { - fn from(s: String) -> Self { - Self::from_str(s.as_ref()).unwrap() +impl TryFrom for StyleGuide { + type Error = Error; + fn try_from(s: String) -> Result { + Self::from_str(&s) } } -impl From<&String> for StyleGuide { - fn from(s: &String) -> Self { - Self::from_str(s.as_ref()).unwrap() +impl TryFrom<&String> for StyleGuide { + type Error = Error; + fn try_from(s: &String) -> Result { + Self::from_str(s) } } diff --git a/tests/lib.rs b/tests/lib.rs index 0d89a21..25e5dde 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -5,11 +5,11 @@ use decasify::*; #[test] fn cast_from_str() { - let res = titlecase("FIST", "en", "gruber", "default"); + let res = titlecase("FIST", "en", "gruber", "default").unwrap(); assert_eq!(res, "Fist"); - let res = titlecase("FIST", "tr", "", "default"); + let res = titlecase("FIST", "tr", "", "default").unwrap(); assert_eq!(res, "Fıst"); - let res = titlecase("FIST", "tr", "default", "default"); + let res = titlecase("FIST", "tr", "default", "default").unwrap(); assert_eq!(res, "Fıst"); } @@ -20,16 +20,17 @@ fn cast_from_legacy_option() { "en", Some(StyleGuide::DaringFireball), StyleOptions::default(), - ); + ) + .unwrap(); assert_eq!(res, "Fist"); - let res = titlecase("FIST", "en", None, StyleOptions::default()); + let res = titlecase("FIST", "en", None, StyleOptions::default()).unwrap(); assert_eq!(res, "Fist"); } #[test] fn custom_style_guide() { let options: StyleOptions = StyleOptionsBuilder::new().overrides(vec!["fOO"]).build(); - let res = titlecase("foo bar", "tr", StyleGuide::LanguageDefault, options); + let res = titlecase("foo bar", "tr", StyleGuide::LanguageDefault, options).unwrap(); assert_eq!(res, "fOO Bar"); } @@ -53,7 +54,7 @@ macro_rules! case { ($name:ident, $case:expr, $locale:expr, $style:expr, $opts:expr, $input:expr, $expected:expr) => { #[test] fn $name() { - let actual = case($input, $case, $locale, $style, $opts); + let actual = case($input, $case, $locale, $style, $opts).unwrap(); assert_eq!(actual, $expected); } }; @@ -103,7 +104,7 @@ macro_rules! titlecase { ($name:ident, $locale:expr, $style:expr, $opts:expr, $input:expr, $expected:expr) => { #[test] fn $name() { - let actual = titlecase($input, $locale, $style, $opts); + let actual = titlecase($input, $locale, $style, $opts).unwrap(); assert_eq!(actual, $expected); } }; @@ -247,7 +248,7 @@ macro_rules! lowercase { ($name:ident, $locale:expr, $input:expr, $expected:expr) => { #[test] fn $name() { - let actual = lowercase($input, $locale); + let actual = lowercase($input, $locale).unwrap(); assert_eq!(actual, $expected); } }; @@ -266,7 +267,7 @@ macro_rules! uppercase { ($name:ident, $locale:expr, $input:expr, $expected:expr) => { #[test] fn $name() { - let actual = uppercase($input, $locale); + let actual = uppercase($input, $locale).unwrap(); assert_eq!(actual, $expected); } }; @@ -285,7 +286,7 @@ macro_rules! sentencecase { ($name:ident, $locale:expr, $input:expr, $expected:expr) => { #[test] fn $name() { - let actual = sentencecase($input, $locale); + let actual = sentencecase($input, $locale).unwrap(); assert_eq!(actual, $expected); } }; From 4d521d37ae598b6a4469d33583775f83f6385063 Mon Sep 17 00:00:00 2001 From: Caleb Maclennan Date: Tue, 11 Nov 2025 23:15:45 +0300 Subject: [PATCH 3/8] feat(typst): Bubble up conversion errors as Typst panics avoiding WASM 'unimplemented' traps --- src/types.rs | 36 +++++++++++++++++++++--------------- typst/src/lib.rs | 40 +++++++++++++++++++++------------------- 2 files changed, 42 insertions(+), 34 deletions(-) diff --git a/src/types.rs b/src/types.rs index a06d61f..5c2754d 100644 --- a/src/types.rs +++ b/src/types.rs @@ -185,10 +185,12 @@ impl TryFrom<&String> for Locale { } } -impl From<&[u8]> for Locale { - fn from(s: &[u8]) -> Self { - let s = String::from_utf8(s.to_vec()).unwrap(); - Self::from_str(s.as_ref()).unwrap() +impl TryFrom<&[u8]> for Locale { + type Error = Error; + + fn try_from(s: &[u8]) -> Result { + let s = String::from_utf8_lossy(s); + Self::from_str(&s) } } @@ -226,10 +228,12 @@ impl TryFrom<&String> for Case { } } -impl From<&[u8]> for Case { - fn from(s: &[u8]) -> Self { - let s = String::from_utf8(s.to_vec()).unwrap(); - Self::from_str(s.as_ref()).unwrap() +impl TryFrom<&[u8]> for Case { + type Error = Error; + + fn try_from(s: &[u8]) -> Result { + let s = String::from_utf8_lossy(s); + Self::from_str(&s) } } @@ -270,13 +274,6 @@ impl TryFrom<&String> for StyleGuide { } } -impl From<&[u8]> for StyleGuide { - fn from(s: &[u8]) -> Self { - let s = String::from_utf8(s.to_vec()).unwrap(); - Self::from_str(s.as_ref()).unwrap() - } -} - impl From> for StyleGuide { fn from(style: Option) -> Self { match style { @@ -285,3 +282,12 @@ impl From> for StyleGuide { } } } + +impl TryFrom<&[u8]> for StyleGuide { + type Error = Error; + + fn try_from(s: &[u8]) -> Result { + let s = String::from_utf8_lossy(s); + Self::from_str(&s) + } +} diff --git a/typst/src/lib.rs b/typst/src/lib.rs index de42c40..d9b676c 100644 --- a/typst/src/lib.rs +++ b/typst/src/lib.rs @@ -1,13 +1,15 @@ // SPDX-FileCopyrightText: © 2024 Caleb Maclennan // SPDX-License-Identifier: LGPL-3.0-only -use anyhow::Result; +use anyhow::{Error, Result}; use wasm_minimal_protocol::{initiate_protocol, wasm_func}; use decasify::{Case, Locale, StyleGuide, StyleOptions, StyleOptionsBuilder}; initiate_protocol!(); +pub type TypstResult = Result, Error>; + #[wasm_func] pub fn decasify( data: &[u8], @@ -15,11 +17,11 @@ pub fn decasify( lang: &[u8], style: &[u8], overrides: &[u8], -) -> Result> { +) -> TypstResult { let chunk = String::from_utf8(data.to_vec())?; - let case = Case::from(case); - let locale = Locale::from(lang); - let style = StyleGuide::from(style); + let case = Case::try_from(case)?; + let locale = Locale::try_from(lang)?; + let style = StyleGuide::try_from(style)?; let overrides_str = String::from_utf8(overrides.to_vec())?; let opts = if overrides_str.is_empty() { StyleOptions::default() @@ -27,14 +29,14 @@ pub fn decasify( let overrides = overrides_str.split(',').map(String::from).collect(); StyleOptionsBuilder::new().overrides(overrides).build() }; - Ok(decasify::case(&chunk, case, locale, style, opts).into_bytes()) + Ok(decasify::case(&chunk, case, locale, style, opts)?.into_bytes()) } #[wasm_func] -pub fn titlecase(data: &[u8], lang: &[u8], style: &[u8], overrides: &[u8]) -> Result> { +pub fn titlecase(data: &[u8], lang: &[u8], style: &[u8], overrides: &[u8]) -> TypstResult { let chunk = String::from_utf8(data.to_vec())?; - let locale = Locale::from(lang); - let style = StyleGuide::from(style); + let locale = Locale::try_from(lang)?; + let style = StyleGuide::try_from(style)?; let overrides_str = String::from_utf8(overrides.to_vec())?; let opts = if overrides_str.is_empty() { StyleOptions::default() @@ -42,26 +44,26 @@ pub fn titlecase(data: &[u8], lang: &[u8], style: &[u8], overrides: &[u8]) -> Re let overrides = overrides_str.split(',').map(String::from).collect(); StyleOptionsBuilder::new().overrides(overrides).build() }; - Ok(decasify::titlecase(&chunk, locale, style, opts).into_bytes()) + Ok(decasify::titlecase(&chunk, locale, style, opts)?.into_bytes()) } #[wasm_func] -pub fn lowercase(data: &[u8], lang: &[u8]) -> Result> { +pub fn lowercase(data: &[u8], lang: &[u8]) -> TypstResult { let chunk = String::from_utf8(data.to_vec())?; - let locale = Locale::from(lang); - Ok(decasify::lowercase(&chunk, locale).into_bytes()) + let locale = Locale::try_from(lang)?; + Ok(decasify::lowercase(&chunk, locale)?.into_bytes()) } #[wasm_func] -pub fn uppercase(data: &[u8], lang: &[u8]) -> Result> { +pub fn uppercase(data: &[u8], lang: &[u8]) -> TypstResult { let chunk = String::from_utf8(data.to_vec())?; - let locale = Locale::from(lang); - Ok(decasify::uppercase(&chunk, locale).into_bytes()) + let locale = Locale::try_from(lang)?; + Ok(decasify::uppercase(&chunk, locale)?.into_bytes()) } #[wasm_func] -pub fn sentencecase(data: &[u8], lang: &[u8]) -> Result> { +pub fn sentencecase(data: &[u8], lang: &[u8]) -> TypstResult { let chunk = String::from_utf8(data.to_vec())?; - let locale = Locale::from(lang); - Ok(decasify::sentencecase(&chunk, locale).into_bytes()) + let locale = Locale::try_from(lang)?; + Ok(decasify::sentencecase(&chunk, locale)?.into_bytes()) } From 253fcd0ee32306c27f80713e23a6ffcd386ab588 Mon Sep 17 00:00:00 2001 From: Caleb Maclennan Date: Wed, 12 Nov 2025 16:40:12 -0700 Subject: [PATCH 4/8] feat(lua): Bubble up conversion errors from into Lua result types --- src/lua.rs | 125 +++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 93 insertions(+), 32 deletions(-) diff --git a/src/lua.rs b/src/lua.rs index 6170487..65bb8e4 100644 --- a/src/lua.rs +++ b/src/lua.rs @@ -4,33 +4,82 @@ use crate::*; use mlua::prelude::*; +use crate::types::{CaseSnafu, LocaleSnafu, StyleGuideSnafu, StyleOptionsSnafu}; +use crate::types::{Error, Result}; + +impl From for LuaError { + fn from(err: Error) -> LuaError { + LuaError::RuntimeError(err.to_string()) + } +} + +impl TryFrom for Locale { + type Error = Error; + fn try_from(s: LuaString) -> Result { + s.to_string_lossy().try_into() + } +} + +impl TryFrom for Case { + type Error = Error; + fn try_from(s: LuaString) -> Result { + s.to_string_lossy().try_into() + } +} + +impl TryFrom for StyleGuide { + type Error = Error; + fn try_from(s: LuaString) -> Result { + s.to_string_lossy().try_into() + } +} + #[mlua::lua_module] fn decasify(lua: &Lua) -> LuaResult { let exports = lua.create_table()?; exports.set( "case", - LuaFunction::wrap_raw::<_, (Chunk, Case, Locale, StyleGuide, StyleOptions)>(case), + lua.create_function( + |_, + (chunk, case_, locale, styleguide, styleoptions): ( + Chunk, + Case, + Locale, + StyleGuide, + StyleOptions, + )| { Ok(case(chunk, case_, locale, styleguide, styleoptions)?) }, + )?, )?; exports.set( "titlecase", - LuaFunction::wrap_raw::<_, (Chunk, Locale, StyleGuide, StyleOptions)>(titlecase), + lua.create_function( + |_, + (chunk, locale, styleguide, styleoptions): ( + Chunk, + Locale, + StyleGuide, + StyleOptions, + )| { Ok(titlecase(chunk, locale, styleguide, styleoptions)?) }, + )?, )?; exports.set( "lowercase", - LuaFunction::wrap_raw::<_, (Chunk, Locale)>(lowercase), + lua.create_function(|_, (chunk, locale): (Chunk, Locale)| Ok(lowercase(chunk, locale)?))?, )?; exports.set( "uppercase", - LuaFunction::wrap_raw::<_, (Chunk, Locale)>(uppercase), + lua.create_function(|_, (chunk, locale): (Chunk, Locale)| Ok(uppercase(chunk, locale)?))?, )?; exports.set( "sentencecase", - LuaFunction::wrap_raw::<_, (Chunk, Locale)>(sentencecase), + lua.create_function(|_, (chunk, locale): (Chunk, Locale)| { + Ok(sentencecase(chunk, locale)?) + })?, )?; let mt = lua.create_table()?; let decasify = lua.create_function( move |_, - (_, chunk, case_, locale, styleguide, opts): ( + (_, chunk, case_, locale, styleguide, styleoptions): ( LuaTable, Chunk, Case, @@ -43,8 +92,8 @@ fn decasify(lua: &Lua) -> LuaResult { case_, locale, styleguide, - opts.unwrap_or_default(), - )) + styleoptions.unwrap_or_default(), + )?) }, )?; mt.set("__call", decasify)?; @@ -58,50 +107,59 @@ fn decasify(lua: &Lua) -> LuaResult { #[cfg_attr(docsrs, doc(cfg(feature = "luamodule")))] impl FromLua for Chunk { fn from_lua(value: LuaValue, _: &Lua) -> LuaResult { - match value { - LuaValue::String(s) => Ok(s.to_string_lossy().into()), - _ => Ok("".into()), - } + Ok(match value { + LuaValue::String(s) => s.to_string_lossy().into(), + _ => "".into(), + }) } } #[cfg_attr(docsrs, doc(cfg(feature = "luamodule")))] impl FromLua for Locale { fn from_lua(value: LuaValue, _: &Lua) -> LuaResult { - match value { - LuaValue::String(s) => Ok(s.to_string_lossy().into()), - LuaValue::Nil => Ok(Self::default()), - _ => unimplemented!(), - } + Ok(match value { + LuaValue::String(s) => s.try_into()?, + LuaValue::Nil => Self::default(), + _ => LocaleSnafu { + input: value.to_string().unwrap_or_default(), + } + .fail()?, + }) } } #[cfg_attr(docsrs, doc(cfg(feature = "luamodule")))] impl FromLua for Case { fn from_lua(value: LuaValue, _: &Lua) -> LuaResult { - match value { - LuaValue::String(s) => Ok(s.to_string_lossy().into()), - LuaValue::Nil => Ok(Self::default()), - _ => unimplemented!(), - } + Ok(match value { + LuaValue::String(s) => s.try_into()?, + LuaValue::Nil => Self::default(), + _ => CaseSnafu { + input: value.to_string().unwrap_or_default(), + } + .fail()?, + }) } } #[cfg_attr(docsrs, doc(cfg(feature = "luamodule")))] impl FromLua for StyleGuide { fn from_lua(value: LuaValue, _: &Lua) -> LuaResult { - match value { - LuaValue::String(s) => Ok(s.to_string_lossy().into()), - LuaValue::Nil => Ok(Self::default()), - _ => unimplemented!(), - } + Ok(match value { + LuaValue::String(s) => s.try_into()?, + LuaValue::Nil => Self::default(), + _ => StyleGuideSnafu { + input: value.to_string().unwrap_or_default(), + } + .fail()?, + }) } } #[cfg_attr(docsrs, doc(cfg(feature = "luamodule")))] impl FromLua for StyleOptions { fn from_lua(value: LuaValue, _: &Lua) -> LuaResult { - match value { + Ok(match value { LuaValue::Table(t) => { let mut builder = StyleOptionsBuilder::new(); if let Ok(overrides_table) = t.get::("overrides") { @@ -113,10 +171,13 @@ impl FromLua for StyleOptions { .collect(); builder = builder.overrides(overrides); } - Ok(builder.build()) + builder.build() + } + LuaValue::Nil => Self::default(), + _ => StyleOptionsSnafu { + input: value.to_string().unwrap_or_default(), } - LuaValue::Nil => Ok(Self::default()), - _ => unimplemented!(), - } + .fail()?, + }) } } From a96355478fde96ef598da0a79f7dd7b992344735 Mon Sep 17 00:00:00 2001 From: Caleb Maclennan Date: Sat, 15 Nov 2025 13:06:08 +0300 Subject: [PATCH 5/8] feat(lua): Enable casting Table and other types to enums via __tostring() --- src/lua.rs | 65 +++++++++++++++++++++++++++++----------------------- src/types.rs | 22 ++++++++++++++++++ 2 files changed, 58 insertions(+), 29 deletions(-) diff --git a/src/lua.rs b/src/lua.rs index 65bb8e4..b1ba797 100644 --- a/src/lua.rs +++ b/src/lua.rs @@ -4,9 +4,22 @@ use crate::*; use mlua::prelude::*; -use crate::types::{CaseSnafu, LocaleSnafu, StyleGuideSnafu, StyleOptionsSnafu}; use crate::types::{Error, Result}; +macro_rules! impl_into_luaresult { + ($($t:ty),*) => { + $( + impl Into> for $t { + fn into(self) -> LuaResult<$t> { + Ok(self) + } + } + )* + }; +} + +impl_into_luaresult!(Locale, Case, StyleGuide, StyleOptions); + impl From for LuaError { fn from(err: Error) -> LuaError { LuaError::RuntimeError(err.to_string()) @@ -107,59 +120,55 @@ fn decasify(lua: &Lua) -> LuaResult { #[cfg_attr(docsrs, doc(cfg(feature = "luamodule")))] impl FromLua for Chunk { fn from_lua(value: LuaValue, _: &Lua) -> LuaResult { - Ok(match value { - LuaValue::String(s) => s.to_string_lossy().into(), - _ => "".into(), - }) + let chunk = match value { + LuaValue::String(s) => s.to_string_lossy(), + _ => String::from(""), + } + .into(); + Ok(chunk) } } #[cfg_attr(docsrs, doc(cfg(feature = "luamodule")))] impl FromLua for Locale { fn from_lua(value: LuaValue, _: &Lua) -> LuaResult { - Ok(match value { + match value { LuaValue::String(s) => s.try_into()?, LuaValue::Nil => Self::default(), - _ => LocaleSnafu { - input: value.to_string().unwrap_or_default(), - } - .fail()?, - }) + _ => value.to_string().unwrap_or_default().try_into()?, + } + .into() } } #[cfg_attr(docsrs, doc(cfg(feature = "luamodule")))] impl FromLua for Case { fn from_lua(value: LuaValue, _: &Lua) -> LuaResult { - Ok(match value { + match value { LuaValue::String(s) => s.try_into()?, LuaValue::Nil => Self::default(), - _ => CaseSnafu { - input: value.to_string().unwrap_or_default(), - } - .fail()?, - }) + _ => value.to_string().unwrap_or_default().try_into()?, + } + .into() } } #[cfg_attr(docsrs, doc(cfg(feature = "luamodule")))] impl FromLua for StyleGuide { fn from_lua(value: LuaValue, _: &Lua) -> LuaResult { - Ok(match value { + match value { LuaValue::String(s) => s.try_into()?, LuaValue::Nil => Self::default(), - _ => StyleGuideSnafu { - input: value.to_string().unwrap_or_default(), - } - .fail()?, - }) + _ => value.to_string().unwrap_or_default().try_into()?, + } + .into() } } #[cfg_attr(docsrs, doc(cfg(feature = "luamodule")))] impl FromLua for StyleOptions { fn from_lua(value: LuaValue, _: &Lua) -> LuaResult { - Ok(match value { + match value { LuaValue::Table(t) => { let mut builder = StyleOptionsBuilder::new(); if let Ok(overrides_table) = t.get::("overrides") { @@ -174,10 +183,8 @@ impl FromLua for StyleOptions { builder.build() } LuaValue::Nil => Self::default(), - _ => StyleOptionsSnafu { - input: value.to_string().unwrap_or_default(), - } - .fail()?, - }) + _ => value.to_string().unwrap_or_default().try_into()?, + } + .into() } } diff --git a/src/types.rs b/src/types.rs index 5c2754d..309bfd0 100644 --- a/src/types.rs +++ b/src/types.rs @@ -124,6 +124,28 @@ impl TryFrom<&str> for StyleOptions { } } +impl TryFrom for StyleOptions { + type Error = Error; + fn try_from(s: String) -> Result { + Self::from_str(&s) + } +} + +impl TryFrom<&String> for StyleOptions { + type Error = Error; + fn try_from(s: &String) -> Result { + Self::from_str(s) + } +} + +impl TryFrom<&[u8]> for StyleOptions { + type Error = Error; + fn try_from(s: &[u8]) -> Result { + let s = String::from_utf8_lossy(s); + Self::from_str(&s) + } +} + #[derive(Debug)] pub struct StyleOptionsBuilder { overrides: Option>, From 32f2cc7ab81879bae7ebbc531a496f002e1399e0 Mon Sep 17 00:00:00 2001 From: Caleb Maclennan Date: Sat, 15 Nov 2025 02:05:42 +0300 Subject: [PATCH 6/8] test(lua): Test that errors propogate into Lua --- spec/decasify_spec.lua | 48 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/spec/decasify_spec.lua b/spec/decasify_spec.lua index 3e1a326..13d39ec 100644 --- a/spec/decasify_spec.lua +++ b/spec/decasify_spec.lua @@ -62,6 +62,15 @@ describe("decasify", function () end) end) + it("should balk at unparsable values", function () + assert.error(function () + case("foo", nil, "foo") + end) + assert.error(function () + case("foo", nil, 1) + end) + end) + it("should not balk at passing all options through", function () local text = "foo: a baz" assert.equal("Foo: A Baz", case(text, "title", "en", "gruber")) @@ -89,11 +98,23 @@ describe("decasify", function () assert.no.error(function () titlecase("foo", "tr") end) + assert.no.error(function () + titlecase("foo", "tr") + end) assert.no.error(function () titlecase("foo") end) end) + it("should balk at unparsable values", function () + assert.error(function () + titlecase("foo", "foo") + end) + assert.error(function () + titlecase("foo", 1) + end) + end) + it("should cooperate with English style guides", function () local text = "foo: a baz" local cmos = "Foo: a Baz" @@ -132,6 +153,15 @@ describe("decasify", function () end) end) + it("should balk at unparsable values", function () + assert.error(function () + lowercase("foo", "foo") + end) + assert.error(function () + lowercase("foo", 1) + end) + end) + it("should default to handling string as English", function () local result = lowercase("IBUPROFIN") assert.equal("ibuprofin", result) @@ -153,6 +183,15 @@ describe("decasify", function () end) end) + it("should balk at unparsable values", function () + assert.error(function () + uppercase("foo", "foo") + end) + assert.error(function () + uppercase("foo", 1) + end) + end) + it("should default to handling string as English", function () local result = uppercase("ibuprofin") assert.equal("IBUPROFIN", result) @@ -174,6 +213,15 @@ describe("decasify", function () end) end) + it("should balk at unparsable values", function () + assert.error(function () + sentencecase("foo", "foo") + end) + assert.error(function () + sentencecase("foo", 1) + end) + end) + it("should default to handling string as English", function () local result = sentencecase("insert BIKE here") assert.equal("Insert bike here", result) From 76f92051ceacdf34af8ce158909e69d8e61c2c84 Mon Sep 17 00:00:00 2001 From: Caleb Maclennan Date: Thu, 13 Nov 2025 23:47:15 +0300 Subject: [PATCH 7/8] feat(python): Bubble up conversion errors into Python result types --- src/python.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/python.rs b/src/python.rs index 00a9a16..3e8d063 100644 --- a/src/python.rs +++ b/src/python.rs @@ -4,6 +4,12 @@ use crate::types::*; use pyo3::prelude::*; +impl From for PyErr { + fn from(err: crate::types::Error) -> Self { + pyo3::exceptions::PyValueError::new_err(err.to_string()) + } +} + #[pymodule] fn decasify(module: &Bound<'_, PyModule>) -> PyResult<()> { module.add_class::()?; @@ -33,7 +39,7 @@ fn case( Some(words) => StyleOptionsBuilder::new().overrides(words).build(), None => StyleOptions::default(), }; - Ok(crate::case(&input, case, locale, style, opts)) + Ok(crate::case(&input, case, locale, style, opts)?) } #[pyfunction] @@ -48,23 +54,23 @@ fn titlecase( Some(words) => StyleOptionsBuilder::new().overrides(words).build(), None => StyleOptions::default(), }; - Ok(crate::titlecase(&input, locale, style, opts)) + Ok(crate::titlecase(&input, locale, style, opts)?) } #[pyfunction] #[pyo3(signature = (input, locale))] fn lowercase(input: String, locale: Locale) -> PyResult { - Ok(crate::lowercase(&input, locale)) + Ok(crate::lowercase(&input, locale)?) } #[pyfunction] #[pyo3(signature = (input, locale))] fn uppercase(input: String, locale: Locale) -> PyResult { - Ok(crate::uppercase(&input, locale)) + Ok(crate::uppercase(&input, locale)?) } #[pyfunction] #[pyo3(signature = (input, locale))] fn sentencecase(input: String, locale: Locale) -> PyResult { - Ok(crate::sentencecase(&input, locale)) + Ok(crate::sentencecase(&input, locale)?) } From 875af81532ba7e990016d14812f8f7c819a4f9bd Mon Sep 17 00:00:00 2001 From: Caleb Maclennan Date: Sat, 15 Nov 2025 23:16:53 +0300 Subject: [PATCH 8/8] feat(walm): Bubble up conversion errors into JS results --- src/wasm.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/wasm.rs b/src/wasm.rs index a1f363e..be1c5b8 100644 --- a/src/wasm.rs +++ b/src/wasm.rs @@ -15,7 +15,7 @@ pub fn case( opts: Option, ) -> Result { let opts = opts.unwrap_or_default(); - Ok(crate::case(input, case, locale, style, opts)) + Ok(crate::case(input, case, locale, style, opts)?) } #[wasm_bindgen] @@ -26,20 +26,20 @@ pub fn titlecase( opts: Option, ) -> Result { let opts = opts.unwrap_or_default(); - Ok(crate::titlecase(input, locale, style, opts)) + Ok(crate::titlecase(input, locale, style, opts)?) } #[wasm_bindgen] pub fn lowercase(input: &str, locale: Locale) -> Result { - Ok(crate::lowercase(input, locale)) + Ok(crate::lowercase(input, locale)?) } #[wasm_bindgen] pub fn uppercase(input: &str, locale: Locale) -> Result { - Ok(crate::uppercase(input, locale)) + Ok(crate::uppercase(input, locale)?) } #[wasm_bindgen] pub fn sentencecase(input: &str, locale: Locale) -> Result { - Ok(crate::sentencecase(input, locale)) + Ok(crate::sentencecase(input, locale)?) }