@@ -475,11 +475,21 @@ internal static IReadOnlyList<string> ExpandGlobStarSegments(string maskedToken)
475475 return [ .. expansions . Select ( prefix => prefix + tail ) . Distinct ( StringComparer . Ordinal ) ] ;
476476 }
477477
478- // In glob syntax a "-" just before the closing "]" is a literal dash, so "[a-]" matches "a" or "-".
479- // DotNet.Glob's tokeniser reads it as a range left open and throws, as it does for the half-typed
480- // "[a-", so the dash is moved to the front of the class ("[-a]", "[!-a]"), a form it does parse.
481- // A class with no closing "]" is left alone and stays unparseable.
482- internal static string MoveTrailingClassDashes ( string token )
478+ // DotNet.Glob reads a bracket class as exactly one token: a single range when it starts "x-y" with
479+ // two letters or two digits, otherwise a plain character list in which "-" is a member and no ranges
480+ // are read. It never combines the two, so "[0-9a-f]" stops at the 0-9 range and misses "c", while
481+ // "[-0-9]" and "[ab-d]" become lists that miss "5" and "c". A trailing dash ("[a-]") makes its
482+ // tokeniser throw outright. So every closed class it would misread is rewritten here into the
483+ // explicit character list it means, which it does read correctly, negation and case folding
484+ // included: "[0-9a-f]" becomes "[0123456789abcdef]" and "[a-z-]" becomes "[-abc...z]". A lone
485+ // letter or digit range is left for DotNet.Glob to match natively, since it reads that form
486+ // correctly and it may span far more characters than are worth listing. A class with no closing
487+ // "]" is left alone and stays unparseable.
488+ // Returns null for a class that cannot be written as a list: one whose ranges span more than
489+ // MaxGlobClassMembers characters, or that has no members at all because every range is reversed.
490+ private const int MaxGlobClassMembers = 4096 ;
491+
492+ internal static string ? ExpandGlobClasses ( string token )
483493 {
484494 if ( ! token . Contains ( ']' ) )
485495 {
@@ -496,35 +506,115 @@ internal static string MoveTrailingClassDashes(string token)
496506 break ;
497507 }
498508
499- int contentStart = open + 1 ;
500- string negation = contentStart < token . Length && token [ contentStart ] == '!' ? "!" : string . Empty ;
501- contentStart += negation . Length ;
502-
503- // A "]" straight after the opening bracket is a member of the class rather than its end.
504- int close = contentStart < token . Length ? token . IndexOf ( ']' , contentStart + 1 ) : - 1 ;
509+ int close = FindGlobClassEnd ( token , open , out bool negated , out int contentStart ) ;
505510 if ( close < 0 )
506511 {
507512 break ;
508513 }
509514
510515 string content = token [ contentStart .. close ] ;
511- result . Append ( token , index , open - index ) . Append ( '[' ) . Append ( negation ) ;
512- if ( content . Length > 1 && content [ ^ 1 ] == '-' )
513- {
514- result . Append ( '-' ) . Append ( content , 0 , content . Length - 1 ) ;
515- }
516- else
516+ string ? rewritten = ! content . Contains ( '-' ) || IsNativeGlobRange ( content )
517+ ? token [ open .. ( close + 1 ) ]
518+ : ToGlobCharacterList ( content , negated ) ;
519+ if ( rewritten is null )
517520 {
518- result . Append ( content ) ;
521+ return null ;
519522 }
520523
521- result . Append ( ']' ) ;
524+ result . Append ( token , index , open - index ) . Append ( rewritten ) ;
522525 index = close + 1 ;
523526 }
524527
525528 return result . Append ( token , index , token . Length - index ) . ToString ( ) ;
526529 }
527530
531+ // Returns the index of the "]" closing the class opened at "open", or -1 if it is never closed.
532+ private static int FindGlobClassEnd ( string token , int open , out bool negated , out int contentStart )
533+ {
534+ contentStart = open + 1 ;
535+ negated = contentStart < token . Length && token [ contentStart ] == '!' ;
536+ if ( negated )
537+ {
538+ contentStart ++ ;
539+ }
540+
541+ // A "]" straight after the opening bracket is a member of the class rather than its end.
542+ return contentStart < token . Length ? token . IndexOf ( ']' , contentStart + 1 ) : - 1 ;
543+ }
544+
545+ private static bool IsNativeGlobRange ( string content ) =>
546+ content . Length == 3 && content [ 1 ] == '-' && content [ 0 ] != ']'
547+ && ( ( char . IsLetter ( content [ 0 ] ) && char . IsLetter ( content [ 2 ] ) ) || ( char . IsAsciiDigit ( content [ 0 ] ) && char . IsAsciiDigit ( content [ 2 ] ) ) ) ;
548+
549+ // Reads the class members with the usual glob rules (a leading "]" and a leading or trailing "-" are
550+ // literal, "x-y" is an inclusive range) and writes them in an order DotNet.Glob reads as a plain list:
551+ // "]" first, so it does not close the class, then "-", so no member pair looks like a range, and "!"
552+ // never first, so it is not read as negation.
553+ private static string ? ToGlobCharacterList ( string content , bool negated )
554+ {
555+ SortedSet < char > members = [ ] ;
556+ int i = 0 ;
557+ while ( i < content . Length )
558+ {
559+ char first = content [ i ] ;
560+ bool isRange = i + 2 < content . Length && content [ i + 1 ] == '-' && ! ( i == 0 && first == ']' ) ;
561+ if ( ! isRange )
562+ {
563+ members . Add ( first ) ;
564+ i ++ ;
565+ continue ;
566+ }
567+
568+ char last = content [ i + 2 ] ;
569+ if ( last - first + members . Count >= MaxGlobClassMembers )
570+ {
571+ return null ;
572+ }
573+
574+ for ( int member = first ; member <= last ; member ++ )
575+ {
576+ members . Add ( ( char ) member ) ;
577+ }
578+
579+ i += 3 ;
580+ }
581+
582+ if ( members . Count == 0 )
583+ {
584+ return null ;
585+ }
586+
587+ StringBuilder list = new ( members . Count + 3 ) ;
588+ list . Append ( '[' ) ;
589+ if ( negated )
590+ {
591+ list . Append ( '!' ) ;
592+ }
593+
594+ if ( members . Remove ( ']' ) )
595+ {
596+ list . Append ( ']' ) ;
597+ }
598+
599+ if ( members . Remove ( '-' ) )
600+ {
601+ list . Append ( '-' ) ;
602+ }
603+
604+ bool hasBang = members . Remove ( '!' ) ;
605+ foreach ( char member in members )
606+ {
607+ list . Append ( member ) ;
608+ }
609+
610+ if ( hasBang )
611+ {
612+ list . Append ( '!' ) ;
613+ }
614+
615+ return list . Append ( ']' ) . ToString ( ) ;
616+ }
617+
528618 // Returns null for a token that cannot be parsed, so each caller can decide what ignoring it means.
529619 private static Glob [ ] ? ResolveGlob ( string filterToken , TextFilterCaseSensitivity caseSensitivity )
530620 {
@@ -534,8 +624,8 @@ internal static string MoveTrailingClassDashes(string token)
534624 {
535625 try
536626 {
537- string maskedToken = MaskPathSeparators ( MoveTrailingClassDashes ( filterToken ) ) ;
538- globs = [ .. ExpandGlobStarSegments ( maskedToken ) . Select ( pattern => caseSensitivity is TextFilterCaseSensitivity . CaseInsensitive
627+ string ? expandedToken = ExpandGlobClasses ( filterToken ) ;
628+ globs = expandedToken is null ? null : [ .. ExpandGlobStarSegments ( MaskPathSeparators ( expandedToken ) ) . Select ( pattern => caseSensitivity is TextFilterCaseSensitivity . CaseInsensitive
539629 ? Glob . Parse ( pattern , CaseInsensitiveGlobOptions )
540630 : Glob . Parse ( pattern ) ) ] ;
541631 }
0 commit comments