2222import static com .mirkoddd .sift .core .Sift .fromWordBoundary ;
2323import static com .mirkoddd .sift .core .Sift .oneOrMore ;
2424import static com .mirkoddd .sift .core .Sift .optional ;
25+ import static com .mirkoddd .sift .core .Sift .zeroOrMore ;
2526import static com .mirkoddd .sift .core .SiftPatterns .anyOf ;
2627import static com .mirkoddd .sift .core .SiftPatterns .literal ;
27- import static com .mirkoddd .sift .core .SiftPatterns .negativeLookahead ;
2828import static com .mirkoddd .sift .core .SiftPatterns .positiveLookahead ;
2929
3030import org .junit .jupiter .api .DisplayName ;
@@ -465,4 +465,106 @@ void extractHydroxidesFromText() {
465465
466466 assertEquals (expected , extractedMolecules , "Extraction failed: Regex matched incorrect patterns or missed valid ones" );
467467 }
468+
469+ @ Test
470+ @ DisplayName ("Should validate international compound names with distinct rules for cased and caseless scripts" )
471+ void testInternationalCompoundNames () {
472+ // --- RULE A: CASED NAMES (Western/Latin, Cyrillic, Greek, etc.) ---
473+
474+ // 1. Define the starting capital letter
475+ CharacterConnector <Fragment > upperCaseLettersUnicode = exactly (1 ).upperCaseLettersUnicode ();
476+
477+ // 2. Define a single word: a capital letter followed by up to 49 lowercase letters.
478+ // Using withoutBacktracking() to optimize performance and prevent catastrophic backtracking.
479+ SiftPattern <Fragment > casedWord = upperCaseLettersUnicode
480+ .followedBy (between (0 , 49 ).lowerCaseLettersUnicode ().withoutBacktracking ());
481+
482+ // 3. Define allowed separators for compound names (space or hyphen)
483+ SiftPattern <Fragment > nameSeparators = anyOf (
484+ exactly (1 ).character (' ' ),
485+ exactly (1 ).character ('-' )
486+ );
487+
488+ // 4. Define an extension: a separator followed by another properly cased word
489+ SiftPattern <Fragment > casedExtension = exactly (1 ).of (nameSeparators )
490+ .followedBy (casedWord );
491+
492+ // 5. Assemble the full cased name: a base word followed by any number of valid extensions
493+ Connector <Fragment > casedName = Sift .fromAnywhere ().of (casedWord )
494+ .followedBy (zeroOrMore ().of (casedExtension ));
495+
496+ // --- RULE B: CASELESS NAMES (Kanji, Arabic, Hebrew, etc.) ---
497+
498+ // 1. Define a single caseless word (length 1 to 50)
499+ SiftPattern <Fragment > caselessWord = between (1 , 50 ).caselessLettersUnicode ().withoutBacktracking ();
500+
501+ // 2. Define an extension: a space followed by another caseless word (hyphens are rare here)
502+ SiftPattern <Fragment > caselessExtension = exactly (1 ).character (' ' )
503+ .followedBy (caselessWord );
504+
505+ // 3. Assemble the full caseless name
506+ Connector <Fragment > caselessName = Sift .fromAnywhere ().of (caselessWord )
507+ .followedBy (zeroOrMore ().of (caselessExtension ));
508+
509+ // --- COMBINED CONDITIONAL LOGIC ---
510+
511+ // If the name starts with an uppercase letter, apply the strict cased rules.
512+ // Otherwise, assume it's a caseless script and apply the caseless rules.
513+ SiftPattern <Fragment > name = SiftPatterns .ifFollowedBy (upperCaseLettersUnicode )
514+ .thenUse (casedName )
515+ .otherwiseUse (caselessName );
516+
517+ // Anchor to start and end to ensure the entire string is just the name
518+ String regex = Sift .fromStart ()
519+ .of (name )
520+ .andNothingElse ()
521+ .shake ();
522+
523+ // --- TESTS ---
524+ assertMatches (regex , "桜" ); // Single caseless word (Kanji) - PASS
525+ assertMatches (regex , "Günther" ); // Single cased word (Latin with umlaut) - PASS
526+ assertMatches (regex , "Gian Maria" ); // Compound cased name with space - PASS
527+ assertMatches (regex , "Jean-Luc" ); // Compound cased name with hyphen - PASS
528+
529+ // Invalid formats rejected by strict structural rules
530+ assertDoesNotMatch (regex , "Gian Maria" ); // Fails: Double space not allowed (Security!)
531+ assertDoesNotMatch (regex , "Gian maria" ); // Fails: Second word missing capital letter
532+ assertDoesNotMatch (regex , " Gian" ); // Fails: Cannot start with a space
533+ assertDoesNotMatch (regex , "Gian " ); // Fails: Cannot end with a space
534+ }
535+
536+ @ Test
537+ @ DisplayName ("Should validate strings containing Unicode symbols like currencies or mathematical operators" )
538+ void testUnicodeSymbols () {
539+ // Goal: Match a generic price tag, mathematical notation, or symbol-based input.
540+ // Format: [Symbol] [Optional Space] [Digits]
541+
542+ // 1. The symbol (Currency, Math, Dingbats, etc. -> \p{S})
543+ CharacterConnector <Fragment > symbol = exactly (1 ).symbolsUnicode ();
544+
545+ // 2. An optional space separator
546+ Connector <Fragment > optionalSpace = optional ().whitespace ();
547+
548+ // 3. The numeric value
549+ Connector <Fragment > amount = oneOrMore ().digits ();
550+
551+ String regex = Sift .fromStart ()
552+ .of (symbol )
553+ .followedBy (optionalSpace )
554+ .followedBy (amount )
555+ .andNothingElse ()
556+ .shake ();
557+
558+ // Valid symbol inputs
559+ assertMatches (regex , "€ 100" ); // Euro symbol (Currency)
560+ assertMatches (regex , "¥5000" ); // Yen symbol (No space)
561+ assertMatches (regex , "$ 1" ); // Dollar symbol (Currency)
562+ assertMatches (regex , "∑ 42" ); // Sum symbol (Mathematical)
563+ assertMatches (regex , "♥ 2" ); // Heart symbol (Miscellaneous/Dingbat)
564+
565+ // Invalid cases where standard letters are used instead of Unicode symbols
566+ assertDoesNotMatch (regex , "EUR 100" ); // 'E' is a letter, not a symbol (\p{S})
567+ assertDoesNotMatch (regex , "USD 50" ); // Fails on letters
568+ assertDoesNotMatch (regex , "100" ); // Missing the required symbol at the start
569+ }
468570}
0 commit comments