From 77bf2bc7495db69709ff41d3f187659f0422bd19 Mon Sep 17 00:00:00 2001 From: harehare Date: Wed, 24 Jun 2026 22:08:03 +0900 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(mq-lang):=20add=20ascii=5Fdown?= =?UTF-8?q?case=20and=20ascii=5Fupcase=20builtin=20functions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds jq-compatible ascii_downcase/ascii_upcase, which fold only ASCII letters (A-Z/a-z) and leave other Unicode characters untouched, unlike the existing downcase/upcase which use full Unicode case folding. --- crates/mq-check/src/builtin.rs | 22 +++++++++++- crates/mq-lang/src/eval/builtin.rs | 42 +++++++++++++++++++++++ crates/mq-lang/tests/integration_tests.rs | 19 ++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/crates/mq-check/src/builtin.rs b/crates/mq-check/src/builtin.rs index 255fcdce6..4f1dc5122 100644 --- a/crates/mq-check/src/builtin.rs +++ b/crates/mq-check/src/builtin.rs @@ -289,7 +289,15 @@ fn register_string(ctx: &mut InferenceContext) { // Case conversion and trimming: string -> string register_many( ctx, - &["downcase", "upcase", "trim", "ltrim", "rtrim"], + &[ + "downcase", + "upcase", + "ascii_downcase", + "ascii_upcase", + "trim", + "ltrim", + "rtrim", + ], vec![Type::String], Type::String, ); @@ -356,6 +364,8 @@ fn register_string(ctx: &mut InferenceContext) { &[ "downcase", "upcase", + "ascii_downcase", + "ascii_upcase", "trim", "ltrim", "rtrim", @@ -1377,8 +1387,11 @@ mod tests { #[rstest] #[case::downcase("downcase(\"HELLO\")", true)] #[case::upcase("upcase(\"hello\")", true)] + #[case::ascii_downcase("ascii_downcase(\"HELLO\")", true)] + #[case::ascii_upcase("ascii_upcase(\"hello\")", true)] #[case::trim("trim(\" hello \")", true)] #[case::downcase_number("downcase(42)", false)] // Should fail: wrong type + #[case::ascii_downcase_number("ascii_downcase(42)", false)] // Should fail: wrong type #[case::rtrim("rtrim(\" hello \")", true)] #[case::rindex("rindex(\"hello world hello\", \"hello\")", true)] #[case::capture("capture(\"hello 42\", \"(?P\\\\w+)\")", true)] @@ -1650,6 +1663,7 @@ mod tests { #[rstest] #[case::string_to_upcase("\"hello\" | upcase", true)] + #[case::string_to_ascii_upcase("\"hello\" | ascii_upcase", true)] #[case::string_to_trim("\" hello \" | trim", true)] #[case::number_to_abs("-42 | abs", true)] #[case::string_to_len("\"hello\" | len", true)] @@ -1658,6 +1672,7 @@ mod tests { #[case::chained_split_to_len("\"hello\" | split(\",\") | len", true)] #[case::chained_array_reverse_first("[1,2,3] | reverse | first", true)] #[case::number_to_upcase("42 | upcase", false)] // Number piped to string function + #[case::number_to_ascii_upcase("42 | ascii_upcase", false)] // Number piped to string function fn test_pipe_type_propagation(#[case] code: &str, #[case] should_succeed: bool) { let result = check_types(code); assert_eq!( @@ -1732,6 +1747,7 @@ mod tests { #[rstest] #[case::abs_string("abs(\"not a number\")", false)] // wrong argument type #[case::downcase_number("downcase(42)", false)] // wrong argument type + #[case::ascii_downcase_number("ascii_downcase(42)", false)] // wrong argument type #[case::ceil_string("ceil(\"hello\")", false)] // wrong argument type #[case::floor_bool("floor(true)", false)] // wrong argument type #[case::len_no_args("len()", true)] // valid: root-level dynamic piped input satisfies the arg @@ -1744,6 +1760,7 @@ mod tests { #[case::floor_string("floor(\"hello\")", false)] // wrong argument type #[case::ceil_bool("ceil(false)", false)] // wrong argument type #[case::upcase_number("upcase(42)", false)] // wrong argument type + #[case::ascii_upcase_number("ascii_upcase(42)", false)] // wrong argument type #[case::trim_number("trim(42)", false)] // wrong argument type #[case::ltrim_bool("ltrim(true)", false)] // wrong argument type #[case::rtrim_bool("rtrim(true)", false)] // wrong argument type @@ -1784,7 +1801,9 @@ mod tests { #[case::string_to_abs("\"hello\" | abs", false)] // string piped to number function #[case::string_to_ceil("\"hello\" | ceil", false)] // string piped to number function #[case::number_to_downcase("42 | downcase", false)] // number piped to string function + #[case::number_to_ascii_downcase("42 | ascii_downcase", false)] // number piped to string function #[case::bool_to_upcase("true | upcase", false)] // bool piped to string function + #[case::bool_to_ascii_upcase("true | ascii_upcase", false)] // bool piped to string function #[case::bool_to_trim("true | trim", false)] // bool piped to string function #[case::bool_to_abs("true | abs", false)] // bool piped to number function #[case::string_to_floor("\"hello\" | floor", false)] // string piped to number function @@ -2191,6 +2210,7 @@ mod tests { #[rstest] #[case::number_to_explode("42 | explode", false, "number piped to explode (expects string)")] #[case::number_to_downcase("42 | downcase", false, "number piped to downcase")] + #[case::number_to_ascii_downcase("42 | ascii_downcase", false, "number piped to ascii_downcase")] #[case::number_to_ltrim("42 | ltrim", false, "number piped to ltrim")] #[case::number_to_rtrim("42 | rtrim", false, "number piped to rtrim")] #[case::bool_to_floor("true | floor", false, "bool piped to floor")] diff --git a/crates/mq-lang/src/eval/builtin.rs b/crates/mq-lang/src/eval/builtin.rs index 5a8e7f9d4..2c6714a37 100644 --- a/crates/mq-lang/src/eval/builtin.rs +++ b/crates/mq-lang/src/eval/builtin.rs @@ -914,6 +914,18 @@ fn downcase_impl(_: &Ident, _: &RuntimeValue, args: Args, _: &SharedEnv) -> Resu } } +#[mq_macros::mq_fn(name = "ascii_downcase", params = Fixed(1))] +fn ascii_downcase_impl(_: &Ident, _: &RuntimeValue, args: Args, _: &SharedEnv) -> Result { + match args.as_slice() { + [node @ RuntimeValue::Markdown(_, _)] => node + .markdown_node() + .map(|md| Ok(node.update_markdown_value(md.value().to_ascii_lowercase().as_str()))) + .unwrap_or_else(|| Ok(RuntimeValue::NONE)), + [RuntimeValue::String(s)] => Ok(s.to_ascii_lowercase().into()), + _ => Ok(RuntimeValue::NONE), + } +} + #[mq_macros::mq_fn(name = "gsub", params = Fixed(3))] fn gsub_impl(ident: &Ident, _: &RuntimeValue, mut args: Args, _: &SharedEnv) -> Result { match args.as_mut_slice() { @@ -1073,6 +1085,20 @@ fn upcase_impl(ident: &Ident, _: &RuntimeValue, mut args: Args, _: &SharedEnv) - } } +#[mq_macros::mq_fn(name = "ascii_upcase", params = Fixed(1))] +fn ascii_upcase_impl(ident: &Ident, _: &RuntimeValue, mut args: Args, _: &SharedEnv) -> Result { + match args.as_mut_slice() { + [node @ RuntimeValue::Markdown(_, _)] => node + .markdown_node() + .map(|md| Ok(node.update_markdown_value(md.value().to_ascii_uppercase().as_str()))) + .unwrap_or_else(|| Ok(RuntimeValue::NONE)), + [RuntimeValue::String(s)] => Ok(s.to_ascii_uppercase().into()), + [RuntimeValue::None] => Ok(RuntimeValue::NONE), + [a] => Err(Error::InvalidTypes(ident.to_string(), vec![std::mem::take(a)])), + _ => unreachable!("ascii_upcase should always receive exactly one argument"), + } +} + #[mq_macros::mq_fn(name = "update", params = Fixed(2))] fn update_impl(_: &Ident, _: &RuntimeValue, mut args: Args, _: &SharedEnv) -> Result { match args.as_mut_slice() { @@ -3874,6 +3900,7 @@ mq_macros::builtin_dispatch! { IS_NOT_REGEX_MATCH, CAPTURE, DOWNCASE, + ASCII_DOWNCASE, GSUB, REPLACE, REPEAT, @@ -3883,6 +3910,7 @@ mq_macros::builtin_dispatch! { LTRIM, RTRIM, UPCASE, + ASCII_UPCASE, UPDATE, SLICE, POW, @@ -4865,6 +4893,13 @@ pub static BUILTIN_FUNCTION_DOC: LazyLock params: &["input"], }, ); + map.insert( + SmolStr::new("ascii_downcase"), + BuiltinFunctionDoc { + description: "Converts ASCII uppercase letters (A-Z) in the given string to lowercase, leaving all other characters unchanged.", + params: &["input"], + }, + ); map.insert( SmolStr::new("gsub"), BuiltinFunctionDoc { @@ -4928,6 +4963,13 @@ pub static BUILTIN_FUNCTION_DOC: LazyLock params: &["input"], }, ); + map.insert( + SmolStr::new("ascii_upcase"), + BuiltinFunctionDoc { + description: "Converts ASCII lowercase letters (a-z) in the given string to uppercase, leaving all other characters unchanged.", + params: &["input"], + }, + ); map.insert( SmolStr::new(constants::builtins::SLICE), BuiltinFunctionDoc { diff --git a/crates/mq-lang/tests/integration_tests.rs b/crates/mq-lang/tests/integration_tests.rs index 6cb8efc28..074913569 100644 --- a/crates/mq-lang/tests/integration_tests.rs +++ b/crates/mq-lang/tests/integration_tests.rs @@ -2508,6 +2508,16 @@ fn engine() -> DefaultEngine { #[case::index_string_simple(r##"index("hello", "e")"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::Number(1.into())].into()))] #[case::set_ref_simple(r##"to_markdown("[link][id]\n\n[id]: url") | first() | set_ref("newlabel") | .link_ref.label"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::String("newlabel".to_string())].into()))] #[case::downcase_simple(r##"downcase("ABC")"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::String("abc".to_string())].into()))] +#[case::ascii_downcase_simple(r##"ascii_downcase("ABC")"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::String("abc".to_string())].into()))] +// downcase is Unicode-aware: "À" (U+00C0) is also lowercased to "à" (U+00E0) +#[case::downcase_non_ascii(r##"downcase("ABCÀ")"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::String("abcà".to_string())].into()))] +// ascii_downcase only folds ASCII letters: "À" is left untouched, unlike downcase above +#[case::ascii_downcase_non_ascii(r##"ascii_downcase("ABCÀ")"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::String("abcÀ".to_string())].into()))] +#[case::ascii_upcase_simple(r##"ascii_upcase("abc")"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::String("ABC".to_string())].into()))] +// upcase is Unicode-aware: "à" (U+00E0) is also uppercased to "À" (U+00C0) +#[case::upcase_non_ascii(r##"upcase("abcà")"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::String("ABCÀ".to_string())].into()))] +// ascii_upcase only folds ASCII letters: "à" is left untouched, unlike upcase above +#[case::ascii_upcase_non_ascii(r##"ascii_upcase("abcà")"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::String("ABCà".to_string())].into()))] #[case::gsub_simple(r##"gsub("a1b2", "\\d", "x")"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::String("axbx".to_string())].into()))] #[case::regex_match_simple(r##"regex_match("a1b2", "\\d")"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::Array(vec![RuntimeValue::String("1".to_string()), RuntimeValue::String("2".to_string())])].into()))] #[case::slice_simple(r##"slice("abcdef", 1, 4)"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::String("bcd".to_string())].into()))] @@ -3064,6 +3074,10 @@ fn engine() -> DefaultEngine { #[case::upcase_none("upcase(None)", vec![RuntimeValue::None], Ok(vec![RuntimeValue::None].into()))] // downcase: None input → None #[case::downcase_none("downcase(None)", vec![RuntimeValue::None], Ok(vec![RuntimeValue::None].into()))] +// ascii_upcase: None input → None +#[case::ascii_upcase_none("ascii_upcase(None)", vec![RuntimeValue::None], Ok(vec![RuntimeValue::None].into()))] +// ascii_downcase: None input → None +#[case::ascii_downcase_none("ascii_downcase(None)", vec![RuntimeValue::None], Ok(vec![RuntimeValue::None].into()))] // trim: None input → None #[case::trim_none("trim(None)", vec![RuntimeValue::None], Ok(vec![RuntimeValue::None].into()))] // ltrim: None input → None @@ -3126,6 +3140,7 @@ fn engine() -> DefaultEngine { #[case::ltrim_markdown(r#"to_h(" test ", 1) | ltrim | type"#, vec![RuntimeValue::None], Ok(vec![RuntimeValue::String("markdown".to_string())].into()))] #[case::rtrim_markdown(r#"to_h(" test ", 1) | rtrim | type"#, vec![RuntimeValue::None], Ok(vec![RuntimeValue::String("markdown".to_string())].into()))] #[case::upcase_markdown(r#"to_h("test", 1) | upcase | type"#, vec![RuntimeValue::None], Ok(vec![RuntimeValue::String("markdown".to_string())].into()))] +#[case::ascii_upcase_markdown(r#"to_h("test", 1) | ascii_upcase | type"#, vec![RuntimeValue::None], Ok(vec![RuntimeValue::String("markdown".to_string())].into()))] // sub/div/mod: string arguments are converted to numbers #[case::sub_strings(r#"sub("10", "3")"#, vec![RuntimeValue::None], Ok(vec![RuntimeValue::Number(7.into())].into()))] #[case::div_strings(r#"div("10", "2")"#, vec![RuntimeValue::None], Ok(vec![RuntimeValue::Number(5.into())].into()))] @@ -3162,6 +3177,8 @@ fn engine() -> DefaultEngine { #[case::mul_markdown_number(r#"to_h("ab", 1) | mul(2) | type"#, vec![RuntimeValue::None], Ok(vec![RuntimeValue::String("markdown".to_string())].into()))] // downcase: with Markdown input #[case::downcase_markdown(r#"to_h("TEST", 1) | downcase | type"#, vec![RuntimeValue::None], Ok(vec![RuntimeValue::String("markdown".to_string())].into()))] +// ascii_downcase: with Markdown input +#[case::ascii_downcase_markdown(r#"to_h("TEST", 1) | ascii_downcase | type"#, vec![RuntimeValue::None], Ok(vec![RuntimeValue::String("markdown".to_string())].into()))] // wikilink selector #[case::wikilink_select( r#"to_markdown("[[target]]") | first() | .wikilink"#, @@ -3355,6 +3372,8 @@ fn test_eval(mut engine: Engine, #[case] program: &str, #[case] input: Vec