PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.0.3
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.0.3
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / class-glob-matcher.php

class-glob-matcher.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.0.3, at includes/class-glob-matcher.php

150 lines 3.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Glob_Matcher — translate user-friendly glob patterns to PCRE for the
4 * cache engine's exclusion checks.
5 *
6 * Supported syntax (shell-style glob, NOT full PCRE):
7 * * → match any run of characters (greedy), including '/'
8 * ? → match exactly one character
9 * [abc] → character class
10 * \* → literal asterisk (escape)
11 *
12 * Compiles each user pattern once, caches the compiled regex in a
13 * static array for the request, then matches with a single preg_match.
14 *
15 * A bare substring (no glob metacharacters) keeps the legacy "contains"
16 * semantics: `/cart` matches `/cart`, `/cart/items`, `/foo/cart/bar`.
17 * Adding ANY of `* ? [` switches the pattern to anchored glob mode:
18 * `/cart/*` matches `/cart/items` but NOT `/foo/cart/bar`.
19 *
20 * @package XSpeed
21 */
22
23 declare(strict_types=1);
24
25 namespace XSpeed;
26
27 defined( 'ABSPATH' ) || exit;
28
29 final class Glob_Matcher {
30
31 /**
32 * @var array<string,string> pattern => compiled regex
33 */
34 private static $compiled = array();
35
36 /**
37 * Does any of `$patterns` match `$subject`?
38 *
39 * @param string[] $patterns
40 */
41 public static function any_match( array $patterns, string $subject ): bool {
42 foreach ( $patterns as $p ) {
43 $p = (string) $p;
44 if ( '' === $p ) {
45 continue;
46 }
47 if ( self::matches( $p, $subject ) ) {
48 return true;
49 }
50 }
51 return false;
52 }
53
54 public static function matches( string $pattern, string $subject ): bool {
55 $regex = self::compile( $pattern );
56 // Anchored glob (regex returned starts with '#^') vs substring
57 // (regex returned starts with '#'). Both use preg_match the
58 // same way; the anchoring is baked into the pattern.
59 return 1 === preg_match( $regex, $subject );
60 }
61
62 /**
63 * Compile a user pattern to a PCRE delimited with `#`. Cached
64 * for the request lifetime.
65 */
66 public static function compile( string $pattern ): string {
67 if ( isset( self::$compiled[ $pattern ] ) ) {
68 return self::$compiled[ $pattern ];
69 }
70 // Decide mode based on UNESCAPED glob metacharacters only.
71 // `\*` alone keeps the pattern in substring mode (with escapes
72 // resolved); `/cart/*` flips to anchored glob mode.
73 $has_unescaped_glob = (bool) preg_match( '/(?<!\\\\)[*?\[]/', $pattern );
74 if ( $has_unescaped_glob ) {
75 $regex = '#^' . self::glob_to_regex( $pattern ) . '$#';
76 } else {
77 // Substring "contains" mode. Resolve `\X` → `X` first so
78 // `\*` matches a literal asterisk anywhere in the subject.
79 $resolved = preg_replace( '/\\\\(.)/', '$1', $pattern );
80 $regex = '#' . preg_quote( (string) $resolved, '#' ) . '#';
81 }
82 self::$compiled[ $pattern ] = $regex;
83 return $regex;
84 }
85
86 /**
87 * Translate glob syntax → regex body (no delimiters, no anchors).
88 * Mirrors fnmatch's FNM_PATHNAME-disabled semantics: `*` matches
89 * across `/` so `/cart/*` correctly catches `/cart/items/sub`.
90 */
91 private static function glob_to_regex( string $glob ): string {
92 $out = '';
93 $in_class = false;
94 $len = strlen( $glob );
95 $escape = false;
96
97 for ( $i = 0; $i < $len; $i++ ) {
98 $ch = $glob[ $i ];
99
100 if ( $escape ) {
101 $out .= preg_quote( $ch, '#' );
102 $escape = false;
103 continue;
104 }
105
106 if ( '\\' === $ch ) {
107 $escape = true;
108 continue;
109 }
110
111 if ( $in_class ) {
112 if ( ']' === $ch ) {
113 $out .= ']';
114 $in_class = false;
115 } else {
116 // Inside a character class, dash + letters are passed
117 // through; we still preg_quote dangerous chars.
118 $out .= preg_quote( $ch, '#' );
119 }
120 continue;
121 }
122
123 switch ( $ch ) {
124 case '*':
125 $out .= '.*';
126 break;
127 case '?':
128 $out .= '.';
129 break;
130 case '[':
131 $out .= '[';
132 $in_class = true;
133 break;
134 default:
135 $out .= preg_quote( $ch, '#' );
136 }
137 }
138
139 return $out;
140 }
141
142 /**
143 * Test-only: clear the compile cache. Production code never needs
144 * this (PHP request lifetime handles it).
145 */
146 public static function reset_cache(): void {
147 self::$compiled = array();
148 }
149 }
150