From 55510729caf1ff044aa6f21ce7a6f5c9633ed789 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Sun, 18 Jan 2026 19:44:19 +0000 Subject: [PATCH] Add some tests for regex syntax support Some functionality is only supported because the regex is built with the Unicode flag instead of the UnicodeSets flag, but that's done for backwards compatibility as the latter disallows some syntax that may be in use. --- array-parameterized-test/src/lib.rs | 60 ++++++++- src/tests.rs | 199 ++++++++++++++++++++++++++++ 2 files changed, 254 insertions(+), 5 deletions(-) diff --git a/array-parameterized-test/src/lib.rs b/array-parameterized-test/src/lib.rs index 7bb1fd52..9e62e1aa 100644 --- a/array-parameterized-test/src/lib.rs +++ b/array-parameterized-test/src/lib.rs @@ -1,4 +1,4 @@ -use proc_macro2::{Group, TokenStream}; +use proc_macro2::{Group, TokenStream, TokenTree}; use quote::{ToTokens, format_ident, quote}; use syn::{Expr, Ident, ItemConst, ItemFn, Token, parse, parse_macro_input}; @@ -122,11 +122,45 @@ pub fn generate_tests(input: proc_macro::TokenStream) -> proc_macro::TokenStream .step_by(2) .enumerate() .flat_map(|(i, value)| { - let suffix = value - .to_string() + let suffix = string_value(&value) .escape_default() - .collect::() - .replace(|c: char| !c.is_ascii_alphanumeric(), "_"); + .map(|c| match c { + ' ' => "_space_".to_owned(), + '!' => "_exclamation_mark_".to_owned(), + '"' => "_double_quote_".to_owned(), + '#' => "_hash_sign_".to_owned(), + '$' => "_dollar_sign_".to_owned(), + '%' => "_percent_sign_".to_owned(), + '&' => "_ampersand_".to_owned(), + '\'' => "_single_quote_".to_owned(), + '(' => "_opening_parenthesis_".to_owned(), + ')' => "_closing_parenthesis_".to_owned(), + '*' => "_asterisk_".to_owned(), + '+' => "_plus_sign_".to_owned(), + ',' => "_comma_".to_owned(), + '-' => "_hyphen_".to_owned(), + '.' => "_period_".to_owned(), + '/' => "_slash_".to_owned(), + ':' => "_colon_".to_owned(), + ';' => "_semicolon_".to_owned(), + '<' => "_less_than_sign_".to_owned(), + '=' => "_equals_sign_".to_owned(), + '>' => "_greater_than_sign_".to_owned(), + '?' => "_question_mark_".to_owned(), + '@' => "_at_sign_".to_owned(), + '[' => "_opening_square_bracket_".to_owned(), + '\\' => "_backslash_".to_owned(), + ']' => "_closing_square_bracket_".to_owned(), + '^' => "_caret_".to_owned(), + '_' => "_underscore_".to_owned(), + '`' => "_backtick_".to_owned(), + '{' => "_opening_curly_brace_".to_owned(), + '|' => "_vertical_bar_".to_owned(), + '}' => "_closing_curly_brace_".to_owned(), + '~' => "_tilde_".to_owned(), + _ => c.to_string(), + }) + .collect::(); let test_name = format_ident!("_{suffix}"); @@ -142,3 +176,19 @@ pub fn generate_tests(input: proc_macro::TokenStream) -> proc_macro::TokenStream tokens.into() } + +fn string_value(tree: &TokenTree) -> String { + match tree { + TokenTree::Literal(literal) => { + let s = literal.to_string(); + if s.starts_with('"') && s.ends_with('"') { + s.trim_matches('"').to_owned() + } else if s.starts_with('\'') && s.ends_with('\'') { + s.trim_matches('\'').to_owned() + } else { + s + } + } + TokenTree::Group(_) | TokenTree::Ident(_) | TokenTree::Punct(_) => tree.to_string(), + } +} diff --git a/src/tests.rs b/src/tests.rs index b8f241c5..78c46ac1 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -413,3 +413,202 @@ mod unicase { assert!(unicase::eq("\u{03c1}", "\u{03f1}")); } } + +mod case_insensitive_regex { + use array_parameterized_test::{parameterized_test, test_parameter}; + + use super::super::case_insensitive_regex; + + #[test_parameter] + const POSIX_CHARACTER_CLASSES: [&str; 15] = [ + "alnum", "alpha", "blank", "cntrl", "digit", "graph", "lower", "print", "punct", "space", + "upper", "xdigit", "d", "s", "w", + ]; + + #[test_parameter] + const IDENTITY_ESCAPE_CHARS: [char; 62] = [ + ' ', '!', '"', '#', '%', '&', '\'', ',', '-', ':', ';', '<', '=', '>', '@', '_', '`', '~', + 'A', 'C', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'Q', 'R', 'T', 'U', 'V', + 'X', 'Y', 'Z', 'a', 'c', 'e', 'g', 'h', 'i', 'j', 'l', 'm', 'o', 'q', 'x', 'y', 'z', '1', + '2', '3', '4', '5', '6', '7', '8', '9', + ]; + + #[test_parameter] + const SYNTAX_ESCAPE_CHARS: [char; 15] = [ + '$', '(', ')', '*', '+', '.', '/', '?', '[', '\\', ']', '^', '{', '|', '}', + ]; + + // From + #[test_parameter] + const UNICODE_STRING_PROPERTY_NAMES: [&str; 7] = [ + "Basic_Emoji", + "Emoji_Keycap_Sequence", + "RGI_Emoji_Modifier_Sequence", + "RGI_Emoji_Flag_Sequence", + "RGI_Emoji_Tag_Sequence", + "RGI_Emoji_ZWJ_Sequence", + "RGI_Emoji", + ]; + + #[test] + fn should_be_case_insensitive() { + let regex = case_insensitive_regex("A").unwrap(); + + assert!(regex.find("a").is_some()); + } + + #[test] + fn should_support_unicode() { + let regex = case_insensitive_regex("\\u{61}").unwrap(); + + assert!(regex.find("a").is_some()); + + let regex = case_insensitive_regex("[\u{1F604}]").unwrap(); + + assert!(regex.find("\u{1F604}").is_some()); + } + + #[test] + fn should_support_i_modifier() { + let regex = case_insensitive_regex("(?-i:A)").unwrap(); + + assert!(regex.find("A").is_some()); + assert!(regex.find("a").is_none()); + } + + #[test] + fn should_support_m_modifier() { + let regex = case_insensitive_regex("A\n(?m:^)B").unwrap(); + + assert!(regex.find("A\nB").is_some()); + } + + #[test] + fn should_support_s_modifier() { + let regex = case_insensitive_regex("A(?s:.)B").unwrap(); + + assert!(regex.find("A\nB").is_some()); + } + + #[parameterized_test(POSIX_CHARACTER_CLASSES)] + fn does_not_support_named_character_classes(class: &str) { + let err = case_insensitive_regex(&format!("[[:{class}:]]")).unwrap_err(); + + assert_eq!("Invalid atom character", err.to_string()); + } + + #[test] + fn should_not_support_quantifiers_on_lookahead_assertions() { + let err = case_insensitive_regex("(?=a)?b").unwrap_err(); + + assert_eq!("Quantifier not allowed here", err.to_string()); + } + + #[test] + fn should_not_support_octal_escapes() { + let err = case_insensitive_regex("\\01").unwrap_err(); + + assert_eq!("Invalid character escape", err.to_string()); + } + + #[test] + fn should_not_support_character_ranges_with_character_class_boundaries() { + let err = case_insensitive_regex("[\\s-9]").unwrap_err(); + + assert_eq!("Invalid character range", err.to_string()); + } + + #[parameterized_test(IDENTITY_ESCAPE_CHARS)] + fn should_not_support_identity_escapes(c: char) { + let err = case_insensitive_regex(&format!("\\{c}")).unwrap_err(); + + assert_eq!("Invalid character escape", err.to_string()); + } + + #[parameterized_test(SYNTAX_ESCAPE_CHARS)] + fn should_support_syntax_character_escapes(c: char) { + let regex = case_insensitive_regex(&format!("\\{c}")).unwrap(); + + assert!(regex.find(&c.to_string()).is_some()); + } + + #[test] + fn should_support_null_escape() { + let regex = case_insensitive_regex("\\0").unwrap(); + + assert!(regex.find("\0").is_some()); + } + + #[test] + fn should_support_control_character_escapes() { + let regex = case_insensitive_regex("\\cJ").unwrap(); + + assert!(regex.find("\n").is_some()); + } + + #[test] + fn should_not_support_digit_or_underscore_control_character_escapes_in_character_classes() { + let err = case_insensitive_regex("[\\c0]").unwrap_err(); + + assert_eq!("Invalid character escape", err.to_string()); + + let err = case_insensitive_regex("[\\c_]").unwrap_err(); + + assert_eq!("Invalid character escape", err.to_string()); + } + + #[test] + fn should_not_support_backslash_k_when_there_are_no_capturing_groups() { + let err = case_insensitive_regex("\\k").unwrap_err(); + + assert_eq!( + "Backreference to invalid named capture group: name", + err.to_string() + ); + } + + #[test] + fn should_not_support_unescaped_unambiguous_brackets() { + let err = case_insensitive_regex("]").unwrap_err(); + + assert_eq!("Invalid atom character", err.to_string()); + + let err = case_insensitive_regex("{").unwrap_err(); + + assert_eq!("Invalid atom character", err.to_string()); + + let err = case_insensitive_regex("}").unwrap_err(); + + assert_eq!("Invalid atom character", err.to_string()); + } + + #[parameterized_test(UNICODE_STRING_PROPERTY_NAMES)] + fn does_not_support_unicode_property_escapes_for_strings(value: &str) { + let err = case_insensitive_regex(&format!("\\p{{{value}}}")).unwrap_err(); + + assert_eq!("Invalid property name", err.to_string()); + } + + #[test] + fn does_not_support_character_class_intersection() { + let regex = case_insensitive_regex("[\\p{Script_Extensions=Greek}&&\\p{Letter}]").unwrap(); + + assert!(regex.find("\u{03C0}").is_some()); + assert!(regex.find("A").is_some()); + assert!(regex.find("\u{1018A}").is_some()); + } + + #[test] + fn does_not_support_character_class_subtraction() { + let err = case_insensitive_regex("[\\p{Decimal_Number}--\\d]").unwrap_err(); + + assert_eq!("Invalid character range", err.to_string()); + } + + #[test] + fn does_not_support_q_escapes_in_character_classes() { + let err = case_insensitive_regex("^[\\q{\u{1F1FA}}]$").unwrap_err(); + + assert_eq!("Invalid character escape", err.to_string()); + } +}