Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion crates/mq-check/src/builtin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down Expand Up @@ -356,6 +364,8 @@ fn register_string(ctx: &mut InferenceContext) {
&[
"downcase",
"upcase",
"ascii_downcase",
"ascii_upcase",
"trim",
"ltrim",
"rtrim",
Expand Down Expand Up @@ -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<word>\\\\w+)\")", true)]
Expand Down Expand Up @@ -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)]
Expand All @@ -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!(
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")]
Expand Down
42 changes: 42 additions & 0 deletions crates/mq-lang/src/eval/builtin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RuntimeValue, Error> {
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<RuntimeValue, Error> {
match args.as_mut_slice() {
Expand Down Expand Up @@ -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<RuntimeValue, Error> {
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<RuntimeValue, Error> {
match args.as_mut_slice() {
Expand Down Expand Up @@ -3874,6 +3900,7 @@ mq_macros::builtin_dispatch! {
IS_NOT_REGEX_MATCH,
CAPTURE,
DOWNCASE,
ASCII_DOWNCASE,
GSUB,
REPLACE,
REPEAT,
Expand All @@ -3883,6 +3910,7 @@ mq_macros::builtin_dispatch! {
LTRIM,
RTRIM,
UPCASE,
ASCII_UPCASE,
UPDATE,
SLICE,
POW,
Expand Down Expand Up @@ -4865,6 +4893,13 @@ pub static BUILTIN_FUNCTION_DOC: LazyLock<FxHashMap<SmolStr, BuiltinFunctionDoc>
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 {
Expand Down Expand Up @@ -4928,6 +4963,13 @@ pub static BUILTIN_FUNCTION_DOC: LazyLock<FxHashMap<SmolStr, BuiltinFunctionDoc>
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 {
Expand Down
19 changes: 19 additions & 0 deletions crates/mq-lang/tests/integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()))]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()))]
Expand Down Expand Up @@ -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"#,
Expand Down Expand Up @@ -3355,6 +3372,8 @@ fn test_eval(mut engine: Engine, #[case] program: &str, #[case] input: Vec<Runti
#[case::rtrim_non_string("rtrim(42)", vec![RuntimeValue::None],)]
// upcase: non-string/non-markdown/non-none → type error
#[case::upcase_non_string("upcase(42)", vec![RuntimeValue::None],)]
// ascii_upcase: non-string/non-markdown/non-none → type error
#[case::ascii_upcase_non_string("ascii_upcase(42)", vec![RuntimeValue::None],)]
// range: multi-char string with step → error
#[case::range_multichar_with_step(r#"range("aa", "zz", 2)"#, vec![RuntimeValue::None],)]
// range: invalid type → error
Expand Down