Skip to main content

nodejs/stdlib/
path.rs

1//! Node `path` module — both flavors.
2//!
3//! A faithful port of Node's `lib/path.js` (v26): the POSIX flavor (which is
4//! what `require('path')` and `path.posix` yield on a POSIX host) and the
5//! Windows flavor (`path.win32` / `require('path/win32')`), sharing the same
6//! `normalize_string` core. The algorithms mirror the JS line-for-line —
7//! index arithmetic included — so edge cases (UNC roots, device roots, reserved
8//! device names, the CVE-2024-36139 relative-drive guard, trailing-separator
9//! retention) behave exactly as Node's do rather than approximately.
10//!
11//! Node scans paths by UTF-16 code unit; this port scans by `char`. Every
12//! comparison is against ASCII (`/`, `\`, `.`, `:`, drive letters) and every
13//! slice boundary is derived from those comparisons, so the produced strings
14//! are identical — only the intermediate index values differ on astral input.
15
16use super::arg_str;
17use crate::host::with_host;
18use fusevm::Value;
19use indexmap::IndexMap;
20
21pub const METHODS: &[&str] = &[
22    "join",
23    "resolve",
24    "normalize",
25    "basename",
26    "dirname",
27    "extname",
28    "isAbsolute",
29    "relative",
30    "parse",
31    "format",
32    "matchesGlob",
33    "toNamespacedPath",
34    // Legacy internal alias for `toNamespacedPath` (docs-only deprecated DEP0080).
35    "_makeLong",
36];
37
38/// Which separator flavor a `path` call runs under.
39#[derive(Clone, Copy, PartialEq, Eq)]
40pub enum Flavor {
41    Posix,
42    Win32,
43}
44
45impl Flavor {
46    /// Node's per-flavor `isPathSeparator`: win32 accepts both slashes.
47    fn is_sep(self, c: char) -> bool {
48        c == '/' || (self == Flavor::Win32 && c == '\\')
49    }
50
51    fn sep(self) -> char {
52        match self {
53            Flavor::Posix => '/',
54            Flavor::Win32 => '\\',
55        }
56    }
57
58    fn delimiter(self) -> &'static str {
59        match self {
60            Flavor::Posix => ":",
61            Flavor::Win32 => ";",
62        }
63    }
64}
65
66/// `path.sep` / `path.delimiter` constants for `flavor`.
67pub fn constant(flavor: Flavor, name: &str) -> Option<Value> {
68    match name {
69        "sep" => Some(with_host(|h| h.new_str(flavor.sep().to_string()))),
70        "delimiter" => Some(with_host(|h| h.new_str(flavor.delimiter()))),
71        _ => None,
72    }
73}
74
75pub fn call(flavor: Flavor, method: &str, args: &[Value]) -> Option<Result<Value, String>> {
76    let parts: Vec<String> = (0..args.len()).map(|i| arg_str(args, i)).collect();
77    let s = |v: String| Ok(with_host(|h| h.new_str(v)));
78    let one = |i: usize| chars(parts.get(i).map(String::as_str).unwrap_or(""));
79    Some(match method {
80        "join" => s(join(flavor, &parts)),
81        "resolve" => s(resolve(flavor, &parts)),
82        "normalize" => s(normalize(flavor, &one(0))),
83        "basename" => s(basename(
84            flavor,
85            &one(0),
86            // An absent 2nd arg is `undefined`, not `""` — Node skips the
87            // suffix-matching branch entirely in that case.
88            parts.get(1).map(|x| chars(x)).as_deref(),
89        )),
90        "dirname" => s(dirname(flavor, &one(0))),
91        "extname" => s(extname(flavor, &one(0))),
92        "isAbsolute" => Ok(Value::Bool(is_absolute(flavor, &one(0)))),
93        "relative" => s(relative(flavor, &one(0), &one(1))),
94        "parse" => Ok(parse(flavor, &one(0))),
95        "format" => s(format(flavor, args.first())),
96        "matchesGlob" => Ok(Value::Bool(matches_glob(
97            flavor,
98            parts.first().map(String::as_str).unwrap_or(""),
99            parts.get(1).map(|x| x.as_str()).unwrap_or(""),
100        ))),
101        "toNamespacedPath" | "_makeLong" => s(to_namespaced_path(
102            flavor,
103            parts.first().map(String::as_str).unwrap_or(""),
104        )),
105        _ => return None,
106    })
107}
108
109fn chars(s: &str) -> Vec<char> {
110    s.chars().collect()
111}
112
113fn str_of(c: &[char]) -> String {
114    c.iter().collect()
115}
116
117/// Node's `isWindowsDeviceRoot`: an ASCII letter usable as a drive letter.
118fn is_device_root(c: char) -> bool {
119    c.is_ascii_alphabetic()
120}
121
122/// Device names Windows reserves regardless of directory (`CON`, `LPT3`, …).
123const WINDOWS_RESERVED_NAMES: &[&str] = &[
124    "CON",
125    "PRN",
126    "AUX",
127    "NUL",
128    "COM1",
129    "COM2",
130    "COM3",
131    "COM4",
132    "COM5",
133    "COM6",
134    "COM7",
135    "COM8",
136    "COM9",
137    "LPT1",
138    "LPT2",
139    "LPT3",
140    "LPT4",
141    "LPT5",
142    "LPT6",
143    "LPT7",
144    "LPT8",
145    "LPT9",
146    "COM\u{b9}",
147    "COM\u{b2}",
148    "COM\u{b3}",
149    "LPT\u{b9}",
150    "LPT\u{b2}",
151    "LPT\u{b3}",
152];
153
154/// Node's `isWindowsReservedName(path, colonIndex)` — is `path.slice(0,
155/// colonIndex)` a reserved device name? `colon_index` is an `indexOf` result and
156/// may be negative, which in JS counts back from the end (`"CON/".slice(0, -1)`
157/// is `"CON"`), so a colon-less `"CON/"` IS reserved. Reproducing that is what
158/// makes `path.win32.normalize("CON/")` return `.\CON\` rather than `CON\`.
159fn is_reserved_name(p: &[char], colon_index: isize) -> bool {
160    let end = if colon_index < 0 {
161        (p.len() as isize + colon_index).max(0) as usize
162    } else {
163        (colon_index as usize).min(p.len())
164    };
165    let device: String = p[..end].iter().collect::<String>().to_uppercase();
166    WINDOWS_RESERVED_NAMES.contains(&device.as_str())
167}
168
169/// `String.prototype.indexOf(ch, from)` over a char slice, `-1` when absent.
170fn index_of(p: &[char], ch: char, from: usize) -> isize {
171    p.iter()
172        .skip(from)
173        .position(|&c| c == ch)
174        .map(|i| (i + from) as isize)
175        .unwrap_or(-1)
176}
177
178/// Port of Node's `normalizeString`: resolve `.`/`..` and collapse repeated
179/// separators, emitting `separator`-joined segments.
180fn normalize_string(
181    path: &[char],
182    allow_above_root: bool,
183    flavor: Flavor,
184    separator: char,
185) -> String {
186    let mut res: Vec<char> = Vec::new();
187    let mut last_segment_length: isize = 0;
188    let mut last_slash: isize = -1;
189    let mut dots: isize = 0;
190    let mut code: char = '\0';
191    let len = path.len() as isize;
192    let mut i: isize = 0;
193    while i <= len {
194        if i < len {
195            code = path[i as usize];
196        } else if flavor.is_sep(code) {
197            break;
198        } else {
199            code = '/';
200        }
201
202        if flavor.is_sep(code) {
203            if last_slash == i - 1 || dots == 1 {
204                // NOOP — an empty segment or a bare `.`.
205            } else if dots == 2 {
206                let rl = res.len() as isize;
207                if rl < 2
208                    || last_segment_length != 2
209                    || res[res.len() - 1] != '.'
210                    || res[res.len() - 2] != '.'
211                {
212                    if rl > 2 {
213                        let last_slash_index = rl - last_segment_length - 1;
214                        if last_slash_index == -1 {
215                            res.clear();
216                            last_segment_length = 0;
217                        } else {
218                            res.truncate(last_slash_index as usize);
219                            let li = res
220                                .iter()
221                                .rposition(|&c| c == separator)
222                                .map(|p| p as isize)
223                                .unwrap_or(-1);
224                            last_segment_length = res.len() as isize - 1 - li;
225                        }
226                        last_slash = i;
227                        dots = 0;
228                        i += 1;
229                        continue;
230                    } else if rl != 0 {
231                        res.clear();
232                        last_segment_length = 0;
233                        last_slash = i;
234                        dots = 0;
235                        i += 1;
236                        continue;
237                    }
238                }
239                if allow_above_root {
240                    if !res.is_empty() {
241                        res.push(separator);
242                    }
243                    res.push('.');
244                    res.push('.');
245                    last_segment_length = 2;
246                }
247            } else {
248                if !res.is_empty() {
249                    res.push(separator);
250                }
251                res.extend_from_slice(&path[(last_slash + 1) as usize..i as usize]);
252                last_segment_length = i - last_slash - 1;
253            }
254            last_slash = i;
255            dots = 0;
256        } else if code == '.' && dots != -1 {
257            dots += 1;
258        } else {
259            dots = -1;
260        }
261        i += 1;
262    }
263    res.into_iter().collect()
264}
265
266fn cwd() -> String {
267    std::env::current_dir()
268        .map(|d| d.to_string_lossy().into_owned())
269        .unwrap_or_else(|_| "/".into())
270}
271
272// ---------------------------------------------------------------------------
273// resolve
274// ---------------------------------------------------------------------------
275
276/// `path.resolve(p)` against the current directory, for callers outside the JS
277/// dispatcher. `process.argv[1]` is the resolved entry script, not the spelling
278/// the user typed (`node ./x.js` reports `/cwd/x.js`), and reusing the ported
279/// resolver keeps that agreeing with what `path.resolve` reports in-language.
280pub(crate) fn resolve_one(p: &str) -> String {
281    resolve_posix(&[p.to_string()])
282}
283
284fn resolve(flavor: Flavor, args: &[String]) -> String {
285    match flavor {
286        Flavor::Posix => resolve_posix(args),
287        Flavor::Win32 => resolve_win32(args),
288    }
289}
290
291fn resolve_posix(args: &[String]) -> String {
292    if args.is_empty() || (args.len() == 1 && (args[0].is_empty() || args[0] == ".")) {
293        let c = cwd();
294        if c.starts_with('/') {
295            return c;
296        }
297    }
298    let mut resolved = String::new();
299    let mut absolute = false;
300    for p in args.iter().rev() {
301        if absolute {
302            break;
303        }
304        if p.is_empty() {
305            continue;
306        }
307        resolved = std::format!("{p}/{resolved}");
308        absolute = p.starts_with('/');
309    }
310    if !absolute {
311        let c = cwd();
312        resolved = std::format!("{c}/{resolved}");
313        absolute = c.starts_with('/');
314    }
315    let out = normalize_string(&chars(&resolved), !absolute, Flavor::Posix, '/');
316    if absolute {
317        std::format!("/{out}")
318    } else if out.is_empty() {
319        ".".into()
320    } else {
321        out
322    }
323}
324
325fn resolve_win32(args: &[String]) -> String {
326    let f = Flavor::Win32;
327    let mut resolved_device = String::new();
328    let mut resolved_tail = String::new();
329    let mut resolved_absolute = false;
330
331    let mut i = args.len() as isize - 1;
332    while i >= -1 {
333        let path: Vec<char> = if i >= 0 {
334            let p = &args[i as usize];
335            if p.is_empty() {
336                i -= 1;
337                continue;
338            }
339            chars(p)
340        } else if resolved_device.is_empty() {
341            let c = cwd();
342            // Fast path for the current directory. On a POSIX host Node
343            // converts the cwd's forward slashes to backslashes here.
344            if args.is_empty()
345                || (args.len() == 1 && (args[0].is_empty() || args[0] == ".") && c.starts_with('/'))
346            {
347                return c.replace('/', "\\");
348            }
349            chars(&c)
350        } else {
351            // Windows keeps a per-drive cwd in a `=C:` env var; off Windows that
352            // never exists, so Node falls back to `process.cwd()` and only
353            // rewrites it to the bare drive root when the cwd names a DIFFERENT
354            // drive (i.e. it has a `\` at index 2). A POSIX cwd has no such `\`,
355            // so it is used verbatim — which is what `path.win32.resolve('C:')`
356            // reports on this host.
357            let c = cwd();
358            let cc = chars(&c);
359            let drive_mismatch = str_of(&cc[..cc.len().min(2)]).to_lowercase()
360                != resolved_device.to_lowercase()
361                && cc.get(2) == Some(&'\\');
362            if drive_mismatch {
363                chars(&std::format!("{resolved_device}\\"))
364            } else {
365                cc
366            }
367        };
368
369        let len = path.len();
370        let mut root_end: usize = 0;
371        let mut device = String::new();
372        let mut is_absolute = false;
373        let code = *path.first().unwrap_or(&'\0');
374
375        if len == 1 {
376            if f.is_sep(code) {
377                root_end = 1;
378                is_absolute = true;
379            }
380        } else if f.is_sep(code) {
381            is_absolute = true;
382            if f.is_sep(path[1]) {
383                let mut j = 2usize;
384                let mut last = j;
385                while j < len && !f.is_sep(path[j]) {
386                    j += 1;
387                }
388                if j < len && j != last {
389                    let first_part = str_of(&path[last..j]);
390                    last = j;
391                    while j < len && f.is_sep(path[j]) {
392                        j += 1;
393                    }
394                    if j < len && j != last {
395                        last = j;
396                        while j < len && !f.is_sep(path[j]) {
397                            j += 1;
398                        }
399                        if j == len || j != last {
400                            if first_part != "." && first_part != "?" {
401                                device =
402                                    std::format!("\\\\{first_part}\\{}", str_of(&path[last..j]));
403                                root_end = j;
404                            } else {
405                                device = std::format!("\\\\{first_part}");
406                                root_end = 4;
407                            }
408                        }
409                    }
410                }
411            } else {
412                root_end = 1;
413            }
414        } else if is_device_root(code) && path[1] == ':' {
415            device = str_of(&path[..2]);
416            root_end = 2;
417            if len > 2 && f.is_sep(path[2]) {
418                is_absolute = true;
419                root_end = 3;
420            }
421        }
422
423        if !device.is_empty() {
424            if !resolved_device.is_empty() {
425                if device.to_lowercase() != resolved_device.to_lowercase() {
426                    i -= 1;
427                    continue;
428                }
429            } else {
430                resolved_device = device;
431            }
432        }
433
434        if resolved_absolute {
435            if !resolved_device.is_empty() {
436                break;
437            }
438        } else {
439            resolved_tail = std::format!("{}\\{resolved_tail}", str_of(&path[root_end.min(len)..]));
440            resolved_absolute = is_absolute;
441            if is_absolute && !resolved_device.is_empty() {
442                break;
443            }
444        }
445        i -= 1;
446    }
447
448    let tail = normalize_string(&chars(&resolved_tail), !resolved_absolute, f, '\\');
449    if resolved_absolute {
450        std::format!("{resolved_device}{}{tail}", '\\')
451    } else {
452        let joined = std::format!("{resolved_device}{tail}");
453        if joined.is_empty() {
454            ".".into()
455        } else {
456            joined
457        }
458    }
459}
460
461// ---------------------------------------------------------------------------
462// normalize
463// ---------------------------------------------------------------------------
464
465fn normalize(flavor: Flavor, path: &[char]) -> String {
466    match flavor {
467        Flavor::Posix => normalize_posix(path),
468        Flavor::Win32 => normalize_win32(path),
469    }
470}
471
472fn normalize_posix(path: &[char]) -> String {
473    if path.is_empty() {
474        return ".".into();
475    }
476    let is_absolute = path[0] == '/';
477    let trailing = path[path.len() - 1] == '/';
478    let out = normalize_string(path, !is_absolute, Flavor::Posix, '/');
479    if out.is_empty() {
480        return if is_absolute {
481            "/".into()
482        } else if trailing {
483            "./".into()
484        } else {
485            ".".into()
486        };
487    }
488    let out = if trailing {
489        std::format!("{out}/")
490    } else {
491        out
492    };
493    if is_absolute {
494        std::format!("/{out}")
495    } else {
496        out
497    }
498}
499
500fn normalize_win32(path: &[char]) -> String {
501    let f = Flavor::Win32;
502    let len = path.len();
503    if len == 0 {
504        return ".".into();
505    }
506    let mut root_end: usize = 0;
507    let mut device: Option<String> = None;
508    let mut is_absolute = false;
509    let code = path[0];
510
511    if len == 1 {
512        return if code == '/' {
513            "\\".into()
514        } else {
515            str_of(path)
516        };
517    }
518    if f.is_sep(code) {
519        is_absolute = true;
520        if f.is_sep(path[1]) {
521            let mut j = 2usize;
522            let mut last = j;
523            while j < len && !f.is_sep(path[j]) {
524                j += 1;
525            }
526            if j < len && j != last {
527                let first_part = str_of(&path[last..j]);
528                last = j;
529                while j < len && f.is_sep(path[j]) {
530                    j += 1;
531                }
532                if j < len && j != last {
533                    last = j;
534                    while j < len && !f.is_sep(path[j]) {
535                        j += 1;
536                    }
537                    if j == len || j != last {
538                        if first_part == "." || first_part == "?" {
539                            device = Some(std::format!("\\\\{first_part}"));
540                            root_end = 4;
541                            let colon_index = index_of(path, ':', 0);
542                            let end = (colon_index + 1).max(0) as usize;
543                            let possible: Vec<char> = if end >= 4 && end <= len {
544                                path[4..end].to_vec()
545                            } else {
546                                Vec::new()
547                            };
548                            if is_reserved_name(&possible, possible.len() as isize - 1) {
549                                device = Some(std::format!("\\\\?\\{}", str_of(&possible)));
550                                root_end = 4 + possible.len();
551                            }
552                        } else if j == len {
553                            return std::format!("\\\\{first_part}\\{}\\", str_of(&path[last..]));
554                        } else {
555                            device =
556                                Some(std::format!("\\\\{first_part}\\{}", str_of(&path[last..j])));
557                            root_end = j;
558                        }
559                    }
560                }
561            }
562        } else {
563            root_end = 1;
564        }
565    } else {
566        let colon_index = index_of(path, ':', 0);
567        if colon_index > 0 {
568            if is_device_root(code) && colon_index == 1 {
569                device = Some(str_of(&path[..2]));
570                root_end = 2;
571                if len > 2 && f.is_sep(path[2]) {
572                    is_absolute = true;
573                    root_end = 3;
574                }
575            } else if is_reserved_name(path, colon_index) {
576                device = Some(str_of(&path[..(colon_index + 1) as usize]));
577                root_end = (colon_index + 1) as usize;
578            }
579        }
580    }
581
582    let mut tail = if root_end < len {
583        normalize_string(&path[root_end..], !is_absolute, f, '\\')
584    } else {
585        String::new()
586    };
587    if tail.is_empty() && !is_absolute {
588        tail = ".".into();
589    }
590    if !tail.is_empty() && f.is_sep(path[len - 1]) {
591        tail.push('\\');
592    }
593    if !is_absolute && device.is_none() && path.contains(&':') {
594        // CVE-2024-36139: a relative path must never normalize into something
595        // Windows would read as drive-absolute.
596        let tc = chars(&tail);
597        if tc.len() >= 2 && is_device_root(tc[0]) && tc[1] == ':' {
598            return std::format!(".\\{tail}");
599        }
600        let mut index = index_of(path, ':', 0);
601        while index != -1 {
602            if index == len as isize - 1 || f.is_sep(path[(index + 1) as usize]) {
603                return std::format!(".\\{tail}");
604            }
605            index = index_of(path, ':', (index + 1) as usize);
606        }
607    }
608    if is_reserved_name(path, index_of(path, ':', 0)) {
609        return std::format!(".\\{}{tail}", device.unwrap_or_default());
610    }
611    match device {
612        None => {
613            if is_absolute {
614                std::format!("\\{tail}")
615            } else {
616                tail
617            }
618        }
619        Some(d) => {
620            if is_absolute {
621                std::format!("{d}\\{tail}")
622            } else {
623                std::format!("{d}{tail}")
624            }
625        }
626    }
627}
628
629// ---------------------------------------------------------------------------
630// isAbsolute / join
631// ---------------------------------------------------------------------------
632
633fn is_absolute(flavor: Flavor, path: &[char]) -> bool {
634    let len = path.len();
635    if len == 0 {
636        return false;
637    }
638    match flavor {
639        Flavor::Posix => path[0] == '/',
640        Flavor::Win32 => {
641            flavor.is_sep(path[0])
642                || (len > 2 && is_device_root(path[0]) && path[1] == ':' && flavor.is_sep(path[2]))
643        }
644    }
645}
646
647fn join(flavor: Flavor, args: &[String]) -> String {
648    let parts: Vec<&String> = args.iter().filter(|a| !a.is_empty()).collect();
649    if parts.is_empty() {
650        return ".".into();
651    }
652    match flavor {
653        Flavor::Posix => {
654            let joined = parts
655                .iter()
656                .map(|s| s.as_str())
657                .collect::<Vec<_>>()
658                .join("/");
659            normalize_posix(&chars(&joined))
660        }
661        Flavor::Win32 => join_win32(&parts),
662    }
663}
664
665fn join_win32(parts: &[&String]) -> String {
666    let f = Flavor::Win32;
667    let first_part = chars(parts[0]);
668    let mut joined: String = parts
669        .iter()
670        .map(|s| s.as_str())
671        .collect::<Vec<_>>()
672        .join("\\");
673
674    // Avoid turning a plain absolute path into a UNC path, but keep an
675    // intentional UNC prefix (`//server`) intact.
676    let mut needs_replace = true;
677    let mut slash_count = 0usize;
678    if f.is_sep(*first_part.first().unwrap_or(&'\0')) {
679        slash_count += 1;
680        let first_len = first_part.len();
681        if first_len > 1 && f.is_sep(first_part[1]) {
682            slash_count += 1;
683            if first_len > 2 {
684                if f.is_sep(first_part[2]) {
685                    slash_count += 1;
686                } else {
687                    needs_replace = false;
688                }
689            }
690        }
691    }
692    if needs_replace {
693        let jc = chars(&joined);
694        while slash_count < jc.len() && f.is_sep(jc[slash_count]) {
695            slash_count += 1;
696        }
697        if slash_count >= 2 {
698            joined = std::format!("\\{}", str_of(&jc[slash_count..]));
699        }
700    }
701
702    // A reserved device name anywhere in the path suppresses normalization.
703    let jc = chars(&joined);
704    let mut segments: Vec<String> = Vec::new();
705    let mut part = String::new();
706    let mut i = 0usize;
707    while i < jc.len() {
708        if jc[i] == '\\' {
709            if !part.is_empty() {
710                segments.push(std::mem::take(&mut part));
711            }
712            part.clear();
713            while i + 1 < jc.len() && jc[i + 1] == '\\' {
714                i += 1;
715            }
716        } else {
717            part.push(jc[i]);
718        }
719        i += 1;
720    }
721    if !part.is_empty() {
722        segments.push(part);
723    }
724    if segments.iter().any(|p| {
725        let pc = chars(p);
726        let ci = index_of(&pc, ':', 0);
727        ci != -1 && is_reserved_name(&pc, ci)
728    }) {
729        return joined.replace('/', "\\");
730    }
731
732    normalize_win32(&chars(&joined))
733}
734
735// ---------------------------------------------------------------------------
736// relative
737// ---------------------------------------------------------------------------
738
739fn relative(flavor: Flavor, from: &[char], to: &[char]) -> String {
740    match flavor {
741        Flavor::Posix => relative_posix(from, to),
742        Flavor::Win32 => relative_win32(from, to),
743    }
744}
745
746fn relative_posix(from_in: &[char], to_in: &[char]) -> String {
747    if from_in == to_in {
748        return String::new();
749    }
750    let from = chars(&resolve_posix(&[str_of(from_in)]));
751    let to = chars(&resolve_posix(&[str_of(to_in)]));
752    if from == to {
753        return String::new();
754    }
755
756    let from_start = 1isize;
757    let from_end = from.len() as isize;
758    let from_len = from_end - from_start;
759    let to_start = 1isize;
760    let to_len = to.len() as isize - to_start;
761
762    let length = from_len.min(to_len);
763    let mut last_common_sep: isize = -1;
764    let mut i = 0isize;
765    while i < length {
766        let fc = from[(from_start + i) as usize];
767        if fc != to[(to_start + i) as usize] {
768            break;
769        } else if fc == '/' {
770            last_common_sep = i;
771        }
772        i += 1;
773    }
774    if i == length {
775        if to_len > length {
776            if to[(to_start + i) as usize] == '/' {
777                return str_of(&to[(to_start + i + 1) as usize..]);
778            }
779            if i == 0 {
780                return str_of(&to[(to_start + i) as usize..]);
781            }
782        } else if from_len > length {
783            if from[(from_start + i) as usize] == '/' {
784                last_common_sep = i;
785            } else if i == 0 {
786                last_common_sep = 0;
787            }
788        }
789    }
790
791    let mut out = String::new();
792    let mut k = from_start + last_common_sep + 1;
793    while k <= from_end {
794        if k == from_end || from[k as usize] == '/' {
795            out.push_str(if out.is_empty() { ".." } else { "/.." });
796        }
797        k += 1;
798    }
799    std::format!(
800        "{out}{}",
801        str_of(&to[(to_start + last_common_sep) as usize..])
802    )
803}
804
805fn relative_win32(from_in: &[char], to_in: &[char]) -> String {
806    if from_in == to_in {
807        return String::new();
808    }
809    let from_orig = resolve_win32(&[str_of(from_in)]);
810    let to_orig = resolve_win32(&[str_of(to_in)]);
811    if from_orig == to_orig {
812        return String::new();
813    }
814    let from_lc = from_orig.to_lowercase();
815    let to_lc = to_orig.to_lowercase();
816    if from_lc == to_lc {
817        return String::new();
818    }
819
820    let from_orig_c = chars(&from_orig);
821    let to_orig_c = chars(&to_orig);
822    let from = chars(&from_lc);
823    let to = chars(&to_lc);
824
825    // A case-fold that changed the length (e.g. `İ`) invalidates index-parallel
826    // scanning, so Node falls back to segment-wise comparison.
827    if from_orig_c.len() != from.len() || to_orig_c.len() != to.len() {
828        let mut from_split: Vec<String> = from_orig.split('\\').map(str::to_string).collect();
829        let mut to_split: Vec<String> = to_orig.split('\\').map(str::to_string).collect();
830        if from_split.last().is_some_and(String::is_empty) {
831            from_split.pop();
832        }
833        if to_split.last().is_some_and(String::is_empty) {
834            to_split.pop();
835        }
836        let from_len = from_split.len();
837        let to_len = to_split.len();
838        let length = from_len.min(to_len);
839        let mut i = 0usize;
840        while i < length {
841            if from_split[i].to_lowercase() != to_split[i].to_lowercase() {
842                break;
843            }
844            i += 1;
845        }
846        if i == 0 {
847            return to_orig;
848        } else if i == length {
849            if to_len > length {
850                return to_split[i..].join("\\");
851            }
852            if from_len > length {
853                return "..\\".repeat(from_len - 1 - i) + "..";
854            }
855            return String::new();
856        }
857        return "..\\".repeat(from_len - i) + &to_split[i..].join("\\");
858    }
859
860    let mut from_start = 0isize;
861    while (from_start as usize) < from.len() && from[from_start as usize] == '\\' {
862        from_start += 1;
863    }
864    let mut from_end = from.len() as isize;
865    while from_end - 1 > from_start && from[(from_end - 1) as usize] == '\\' {
866        from_end -= 1;
867    }
868    let from_len = from_end - from_start;
869
870    let mut to_start = 0isize;
871    while (to_start as usize) < to.len() && to[to_start as usize] == '\\' {
872        to_start += 1;
873    }
874    let mut to_end = to.len() as isize;
875    while to_end - 1 > to_start && to[(to_end - 1) as usize] == '\\' {
876        to_end -= 1;
877    }
878    let to_len = to_end - to_start;
879
880    let length = from_len.min(to_len);
881    let mut last_common_sep: isize = -1;
882    let mut i = 0isize;
883    while i < length {
884        let fc = from[(from_start + i) as usize];
885        if fc != to[(to_start + i) as usize] {
886            break;
887        } else if fc == '\\' {
888            last_common_sep = i;
889        }
890        i += 1;
891    }
892
893    if i != length {
894        if last_common_sep == -1 {
895            return to_orig;
896        }
897    } else {
898        if to_len > length {
899            if to[(to_start + i) as usize] == '\\' {
900                return str_of(&to_orig_c[(to_start + i + 1) as usize..]);
901            }
902            if i == 2 {
903                return str_of(&to_orig_c[(to_start + i) as usize..]);
904            }
905        }
906        if from_len > length {
907            if from[(from_start + i) as usize] == '\\' {
908                last_common_sep = i;
909            } else if i == 2 {
910                last_common_sep = 3;
911            }
912        }
913        if last_common_sep == -1 {
914            last_common_sep = 0;
915        }
916    }
917
918    let mut out = String::new();
919    let mut k = from_start + last_common_sep + 1;
920    while k <= from_end {
921        if k == from_end || from[k as usize] == '\\' {
922            out.push_str(if out.is_empty() { ".." } else { "\\.." });
923        }
924        k += 1;
925    }
926
927    to_start += last_common_sep;
928    if !out.is_empty() {
929        return std::format!(
930            "{out}{}",
931            str_of(&to_orig_c[to_start as usize..to_end as usize])
932        );
933    }
934    if to_orig_c.get(to_start as usize) == Some(&'\\') {
935        to_start += 1;
936    }
937    str_of(&to_orig_c[to_start as usize..to_end as usize])
938}
939
940// ---------------------------------------------------------------------------
941// toNamespacedPath
942// ---------------------------------------------------------------------------
943
944fn to_namespaced_path(flavor: Flavor, path: &str) -> String {
945    if flavor == Flavor::Posix || path.is_empty() {
946        return path.to_string();
947    }
948    let resolved = resolve_win32(&[path.to_string()]);
949    let rc = chars(&resolved);
950    if rc.len() <= 2 {
951        return path.to_string();
952    }
953    if rc[0] == '\\' {
954        if rc[1] == '\\' && rc[2] != '?' && rc[2] != '.' {
955            return std::format!("\\\\?\\UNC\\{}", str_of(&rc[2..]));
956        }
957    } else if is_device_root(rc[0]) && rc[1] == ':' && rc[2] == '\\' {
958        return std::format!("\\\\?\\{resolved}");
959    }
960    resolved
961}
962
963// ---------------------------------------------------------------------------
964// dirname / basename / extname
965// ---------------------------------------------------------------------------
966
967fn dirname(flavor: Flavor, path: &[char]) -> String {
968    match flavor {
969        Flavor::Posix => dirname_posix(path),
970        Flavor::Win32 => dirname_win32(path),
971    }
972}
973
974fn dirname_posix(path: &[char]) -> String {
975    if path.is_empty() {
976        return ".".into();
977    }
978    let has_root = path[0] == '/';
979    let mut end: isize = -1;
980    let mut matched_slash = true;
981    let mut i = path.len() as isize - 1;
982    while i >= 1 {
983        if path[i as usize] == '/' {
984            if !matched_slash {
985                end = i;
986                break;
987            }
988        } else {
989            matched_slash = false;
990        }
991        i -= 1;
992    }
993    if end == -1 {
994        return if has_root { "/".into() } else { ".".into() };
995    }
996    if has_root && end == 1 {
997        return "//".into();
998    }
999    str_of(&path[..end as usize])
1000}
1001
1002fn dirname_win32(path: &[char]) -> String {
1003    let f = Flavor::Win32;
1004    let len = path.len();
1005    if len == 0 {
1006        return ".".into();
1007    }
1008    let mut root_end: isize = -1;
1009    let mut offset: usize = 0;
1010    let code = path[0];
1011
1012    if len == 1 {
1013        return if f.is_sep(code) {
1014            str_of(path)
1015        } else {
1016            ".".into()
1017        };
1018    }
1019
1020    if f.is_sep(code) {
1021        root_end = 1;
1022        offset = 1;
1023        if f.is_sep(path[1]) {
1024            let mut j = 2usize;
1025            let mut last = j;
1026            while j < len && !f.is_sep(path[j]) {
1027                j += 1;
1028            }
1029            if j < len && j != last {
1030                last = j;
1031                while j < len && f.is_sep(path[j]) {
1032                    j += 1;
1033                }
1034                if j < len && j != last {
1035                    last = j;
1036                    while j < len && !f.is_sep(path[j]) {
1037                        j += 1;
1038                    }
1039                    if j == len {
1040                        return str_of(path);
1041                    }
1042                    if j != last {
1043                        root_end = j as isize + 1;
1044                        offset = j + 1;
1045                    }
1046                }
1047            }
1048        }
1049    } else if is_device_root(code) && path[1] == ':' {
1050        root_end = if len > 2 && f.is_sep(path[2]) { 3 } else { 2 };
1051        offset = root_end as usize;
1052    }
1053
1054    let mut end: isize = -1;
1055    let mut matched_slash = true;
1056    let mut i = len as isize - 1;
1057    while i >= offset as isize {
1058        if f.is_sep(path[i as usize]) {
1059            if !matched_slash {
1060                end = i;
1061                break;
1062            }
1063        } else {
1064            matched_slash = false;
1065        }
1066        i -= 1;
1067    }
1068
1069    if end == -1 {
1070        if root_end == -1 {
1071            return ".".into();
1072        }
1073        end = root_end;
1074    }
1075    str_of(&path[..end as usize])
1076}
1077
1078fn basename(flavor: Flavor, path: &[char], suffix: Option<&[char]>) -> String {
1079    let mut start: isize = 0;
1080    let mut end: isize = -1;
1081    let mut matched_slash = true;
1082
1083    // A `C:` prefix is a root, not a trailing-separator candidate.
1084    if flavor == Flavor::Win32 && path.len() >= 2 && is_device_root(path[0]) && path[1] == ':' {
1085        start = 2;
1086    }
1087
1088    if let Some(sfx) = suffix.filter(|s| !s.is_empty() && s.len() <= path.len()) {
1089        if sfx == path {
1090            return String::new();
1091        }
1092        let mut ext_idx: isize = sfx.len() as isize - 1;
1093        let mut first_non_slash_end: isize = -1;
1094        let mut i = path.len() as isize - 1;
1095        while i >= start {
1096            let code = path[i as usize];
1097            if flavor.is_sep(code) {
1098                if !matched_slash {
1099                    start = i + 1;
1100                    break;
1101                }
1102            } else {
1103                if first_non_slash_end == -1 {
1104                    matched_slash = false;
1105                    first_non_slash_end = i + 1;
1106                }
1107                if ext_idx >= 0 {
1108                    if code == sfx[ext_idx as usize] {
1109                        ext_idx -= 1;
1110                        if ext_idx == -1 {
1111                            end = i;
1112                        }
1113                    } else {
1114                        ext_idx = -1;
1115                        end = first_non_slash_end;
1116                    }
1117                }
1118            }
1119            i -= 1;
1120        }
1121        if start == end {
1122            end = first_non_slash_end;
1123        } else if end == -1 {
1124            end = path.len() as isize;
1125        }
1126        return str_of(&path[start.max(0) as usize..end.max(0) as usize]);
1127    }
1128
1129    let mut i = path.len() as isize - 1;
1130    while i >= start {
1131        if flavor.is_sep(path[i as usize]) {
1132            if !matched_slash {
1133                start = i + 1;
1134                break;
1135            }
1136        } else if end == -1 {
1137            matched_slash = false;
1138            end = i + 1;
1139        }
1140        i -= 1;
1141    }
1142    if end == -1 {
1143        return String::new();
1144    }
1145    str_of(&path[start as usize..end as usize])
1146}
1147
1148fn extname(flavor: Flavor, path: &[char]) -> String {
1149    let mut start: isize = 0;
1150    let mut start_dot: isize = -1;
1151    let mut start_part: isize = 0;
1152    let mut end: isize = -1;
1153    let mut matched_slash = true;
1154    let mut pre_dot_state: isize = 0;
1155
1156    if flavor == Flavor::Win32 && path.len() >= 2 && path[1] == ':' && is_device_root(path[0]) {
1157        start = 2;
1158        start_part = 2;
1159    }
1160
1161    let mut i = path.len() as isize - 1;
1162    while i >= start {
1163        let code = path[i as usize];
1164        if flavor.is_sep(code) {
1165            if !matched_slash {
1166                start_part = i + 1;
1167                break;
1168            }
1169            i -= 1;
1170            continue;
1171        }
1172        if end == -1 {
1173            matched_slash = false;
1174            end = i + 1;
1175        }
1176        if code == '.' {
1177            if start_dot == -1 {
1178                start_dot = i;
1179            } else if pre_dot_state != 1 {
1180                pre_dot_state = 1;
1181            }
1182        } else if start_dot != -1 {
1183            pre_dot_state = -1;
1184        }
1185        i -= 1;
1186    }
1187
1188    if start_dot == -1
1189        || end == -1
1190        || pre_dot_state == 0
1191        || (pre_dot_state == 1 && start_dot == end - 1 && start_dot == start_part + 1)
1192    {
1193        return String::new();
1194    }
1195    str_of(&path[start_dot as usize..end as usize])
1196}
1197
1198// ---------------------------------------------------------------------------
1199// parse / format
1200// ---------------------------------------------------------------------------
1201
1202fn new_parsed(root: &str, dir: &str, base: &str, ext: &str, name: &str) -> Value {
1203    with_host(|h| {
1204        let mut m = IndexMap::new();
1205        m.insert("root".into(), h.new_str(root));
1206        m.insert("dir".into(), h.new_str(dir));
1207        m.insert("base".into(), h.new_str(base));
1208        m.insert("ext".into(), h.new_str(ext));
1209        m.insert("name".into(), h.new_str(name));
1210        h.new_object(m)
1211    })
1212}
1213
1214fn parse(flavor: Flavor, path: &[char]) -> Value {
1215    let (root, dir, base, ext, name) = match flavor {
1216        Flavor::Posix => parse_posix(path),
1217        Flavor::Win32 => parse_win32(path),
1218    };
1219    new_parsed(&root, &dir, &base, &ext, &name)
1220}
1221
1222type Parsed = (String, String, String, String, String);
1223
1224fn parse_posix(path: &[char]) -> Parsed {
1225    let mut root = String::new();
1226    let mut dir = String::new();
1227    let mut base = String::new();
1228    let mut ext = String::new();
1229    let mut name = String::new();
1230    if path.is_empty() {
1231        return (root, dir, base, ext, name);
1232    }
1233    let is_abs = path[0] == '/';
1234    let scan_start: isize = if is_abs {
1235        root = "/".into();
1236        1
1237    } else {
1238        0
1239    };
1240
1241    let mut start_dot: isize = -1;
1242    let mut start_part: isize = 0;
1243    let mut end: isize = -1;
1244    let mut matched_slash = true;
1245    let mut pre_dot_state: isize = 0;
1246    let mut i = path.len() as isize - 1;
1247    while i >= scan_start {
1248        let code = path[i as usize];
1249        if code == '/' {
1250            if !matched_slash {
1251                start_part = i + 1;
1252                break;
1253            }
1254            i -= 1;
1255            continue;
1256        }
1257        if end == -1 {
1258            matched_slash = false;
1259            end = i + 1;
1260        }
1261        if code == '.' {
1262            if start_dot == -1 {
1263                start_dot = i;
1264            } else if pre_dot_state != 1 {
1265                pre_dot_state = 1;
1266            }
1267        } else if start_dot != -1 {
1268            pre_dot_state = -1;
1269        }
1270        i -= 1;
1271    }
1272
1273    if end != -1 {
1274        let s = if start_part == 0 && is_abs {
1275            1
1276        } else {
1277            start_part
1278        };
1279        if start_dot == -1
1280            || pre_dot_state == 0
1281            || (pre_dot_state == 1 && start_dot == end - 1 && start_dot == start_part + 1)
1282        {
1283            base = str_of(&path[s as usize..end as usize]);
1284            name = base.clone();
1285        } else {
1286            name = str_of(&path[s as usize..start_dot as usize]);
1287            base = str_of(&path[s as usize..end as usize]);
1288            ext = str_of(&path[start_dot as usize..end as usize]);
1289        }
1290    }
1291
1292    if start_part > 0 {
1293        dir = str_of(&path[..(start_part - 1) as usize]);
1294    } else if is_abs {
1295        dir = "/".into();
1296    }
1297    (root, dir, base, ext, name)
1298}
1299
1300fn parse_win32(path: &[char]) -> Parsed {
1301    let f = Flavor::Win32;
1302    let mut root = String::new();
1303    let mut dir = String::new();
1304    let mut base = String::new();
1305    let mut ext = String::new();
1306    let mut name = String::new();
1307    let len = path.len();
1308    if len == 0 {
1309        return (root, dir, base, ext, name);
1310    }
1311
1312    let mut root_end: usize = 0;
1313    let code = path[0];
1314
1315    if len == 1 {
1316        if f.is_sep(code) {
1317            root = str_of(path);
1318            dir = root.clone();
1319            return (root, dir, base, ext, name);
1320        }
1321        base = str_of(path);
1322        name = base.clone();
1323        return (root, dir, base, ext, name);
1324    }
1325
1326    if f.is_sep(code) {
1327        root_end = 1;
1328        if f.is_sep(path[1]) {
1329            let mut j = 2usize;
1330            let mut last = j;
1331            while j < len && !f.is_sep(path[j]) {
1332                j += 1;
1333            }
1334            if j < len && j != last {
1335                last = j;
1336                while j < len && f.is_sep(path[j]) {
1337                    j += 1;
1338                }
1339                if j < len && j != last {
1340                    last = j;
1341                    while j < len && !f.is_sep(path[j]) {
1342                        j += 1;
1343                    }
1344                    if j == len {
1345                        root_end = j;
1346                    } else if j != last {
1347                        root_end = j + 1;
1348                    }
1349                }
1350            }
1351        }
1352    } else if is_device_root(code) && path[1] == ':' {
1353        if len <= 2 {
1354            root = str_of(path);
1355            dir = root.clone();
1356            return (root, dir, base, ext, name);
1357        }
1358        root_end = 2;
1359        if f.is_sep(path[2]) {
1360            if len == 3 {
1361                root = str_of(path);
1362                dir = root.clone();
1363                return (root, dir, base, ext, name);
1364            }
1365            root_end = 3;
1366        }
1367    }
1368    if root_end > 0 {
1369        root = str_of(&path[..root_end]);
1370    }
1371
1372    let mut start_dot: isize = -1;
1373    let mut start_part: isize = root_end as isize;
1374    let mut end: isize = -1;
1375    let mut matched_slash = true;
1376    let mut pre_dot_state: isize = 0;
1377    let mut i = len as isize - 1;
1378    while i >= root_end as isize {
1379        let c = path[i as usize];
1380        if f.is_sep(c) {
1381            if !matched_slash {
1382                start_part = i + 1;
1383                break;
1384            }
1385            i -= 1;
1386            continue;
1387        }
1388        if end == -1 {
1389            matched_slash = false;
1390            end = i + 1;
1391        }
1392        if c == '.' {
1393            if start_dot == -1 {
1394                start_dot = i;
1395            } else if pre_dot_state != 1 {
1396                pre_dot_state = 1;
1397            }
1398        } else if start_dot != -1 {
1399            pre_dot_state = -1;
1400        }
1401        i -= 1;
1402    }
1403
1404    if end != -1 {
1405        if start_dot == -1
1406            || pre_dot_state == 0
1407            || (pre_dot_state == 1 && start_dot == end - 1 && start_dot == start_part + 1)
1408        {
1409            base = str_of(&path[start_part as usize..end as usize]);
1410            name = base.clone();
1411        } else {
1412            name = str_of(&path[start_part as usize..start_dot as usize]);
1413            base = str_of(&path[start_part as usize..end as usize]);
1414            ext = str_of(&path[start_dot as usize..end as usize]);
1415        }
1416    }
1417
1418    if start_part > 0 && start_part != root_end as isize {
1419        dir = str_of(&path[..(start_part - 1) as usize]);
1420    } else {
1421        dir = root.clone();
1422    }
1423    (root, dir, base, ext, name)
1424}
1425
1426/// Port of Node's `_format(sep, pathObject)`.
1427fn format(flavor: Flavor, obj: Option<&Value>) -> String {
1428    let Some(obj) = obj else { return String::new() };
1429    let get = |k: &str| {
1430        with_host(|h| match h.get(obj) {
1431            Some(crate::host::JsObj::Object(p)) => match p.get(k) {
1432                Some(Value::Undef) | None => String::new(),
1433                Some(v) => h.str_of(v),
1434            },
1435            _ => String::new(),
1436        })
1437    };
1438    let root = get("root");
1439    let dir_raw = get("dir");
1440    let base_raw = get("base");
1441    let base = if !base_raw.is_empty() {
1442        base_raw
1443    } else {
1444        let ext = get("ext");
1445        let ext = if ext.is_empty() {
1446            String::new()
1447        } else if ext.starts_with('.') {
1448            ext
1449        } else {
1450            std::format!(".{ext}")
1451        };
1452        std::format!("{}{ext}", get("name"))
1453    };
1454    let dir = if !dir_raw.is_empty() {
1455        dir_raw
1456    } else {
1457        root.clone()
1458    };
1459    if dir.is_empty() {
1460        return base;
1461    }
1462    if dir == root {
1463        std::format!("{dir}{base}")
1464    } else {
1465        std::format!("{dir}{}{base}", flavor.sep())
1466    }
1467}
1468
1469/// `path.matchesGlob(path, pattern)` — whether `path` matches the glob `pattern`.
1470/// Supports `*` (within a segment), `**` (across `/`), `?`, `[...]` classes, and
1471/// top-level `{a,b}` brace alternatives — the minimatch-style subset Node uses.
1472/// Under the win32 flavor both slashes are separators, matching Node's
1473/// `matchGlobPattern(path, pattern, /* windows */ true)`.
1474fn matches_glob(flavor: Flavor, path: &str, pattern: &str) -> bool {
1475    let (path, pattern) = match flavor {
1476        Flavor::Posix => (path.to_string(), pattern.to_string()),
1477        Flavor::Win32 => (path.replace('\\', "/"), pattern.replace('\\', "/")),
1478    };
1479    let text: Vec<char> = path.chars().collect();
1480    expand_braces(&pattern)
1481        .iter()
1482        .any(|pat| glob_match(&text, &pat.chars().collect::<Vec<char>>()))
1483}
1484
1485/// Expand top-level `{a,b,c}` alternatives into concrete pattern strings.
1486fn expand_braces(pattern: &str) -> Vec<String> {
1487    let chars: Vec<char> = pattern.chars().collect();
1488    for (i, &c) in chars.iter().enumerate() {
1489        if c != '{' {
1490            continue;
1491        }
1492        let mut depth = 1;
1493        let mut commas: Vec<usize> = Vec::new();
1494        let mut close = None;
1495        for (j, &cj) in chars.iter().enumerate().skip(i + 1) {
1496            match cj {
1497                '{' => depth += 1,
1498                '}' => {
1499                    depth -= 1;
1500                    if depth == 0 {
1501                        close = Some(j);
1502                        break;
1503                    }
1504                }
1505                ',' if depth == 1 => commas.push(j),
1506                _ => {}
1507            }
1508        }
1509        let (Some(close), false) = (close, commas.is_empty()) else {
1510            continue;
1511        };
1512        let prefix: String = chars[..i].iter().collect();
1513        let suffix: String = chars[close + 1..].iter().collect();
1514        let mut bounds = vec![i];
1515        bounds.extend(&commas);
1516        bounds.push(close);
1517        let mut out = Vec::new();
1518        for w in bounds.windows(2) {
1519            let alt: String = chars[w[0] + 1..w[1]].iter().collect();
1520            out.extend(expand_braces(&std::format!("{prefix}{alt}{suffix}")));
1521        }
1522        return out;
1523    }
1524    vec![pattern.to_string()]
1525}
1526
1527/// Recursive glob matcher over char slices. `*` never crosses `/`, `**` does.
1528fn glob_match(t: &[char], p: &[char]) -> bool {
1529    if p.is_empty() {
1530        return t.is_empty();
1531    }
1532    match p[0] {
1533        '*' => {
1534            let double = p.len() >= 2 && p[1] == '*';
1535            let rest = {
1536                let mut k = 0;
1537                while k < p.len() && p[k] == '*' {
1538                    k += 1;
1539                }
1540                &p[k..]
1541            };
1542            if rest.is_empty() {
1543                return double || !t.contains(&'/');
1544            }
1545            let mut ti = 0;
1546            loop {
1547                if glob_match(&t[ti..], rest) {
1548                    return true;
1549                }
1550                if ti >= t.len() {
1551                    return false;
1552                }
1553                if !double && t[ti] == '/' {
1554                    return false;
1555                }
1556                ti += 1;
1557            }
1558        }
1559        '?' => !t.is_empty() && t[0] != '/' && glob_match(&t[1..], &p[1..]),
1560        '[' => match match_class(t.first().copied(), p) {
1561            Some((matched, plen)) => matched && glob_match(&t[1..], &p[plen..]),
1562            // Unterminated `[` is a literal bracket.
1563            None => !t.is_empty() && t[0] == '[' && glob_match(&t[1..], &p[1..]),
1564        },
1565        c => !t.is_empty() && t[0] == c && glob_match(&t[1..], &p[1..]),
1566    }
1567}
1568
1569/// Match `ch` against a `[...]` class starting at `p[0] == '['`. Returns
1570/// `(matched, chars_consumed)`, or `None` when the class is unterminated.
1571fn match_class(ch: Option<char>, p: &[char]) -> Option<(bool, usize)> {
1572    let mut i = 1;
1573    let mut negate = false;
1574    if matches!(p.get(i), Some('!') | Some('^')) {
1575        negate = true;
1576        i += 1;
1577    }
1578    let start = i;
1579    let mut matched = false;
1580    while i < p.len() && (p[i] != ']' || i == start) {
1581        if i + 2 < p.len() && p[i + 1] == '-' && p[i + 2] != ']' {
1582            if let Some(c) = ch {
1583                if p[i] <= c && c <= p[i + 2] {
1584                    matched = true;
1585                }
1586            }
1587            i += 3;
1588        } else {
1589            if ch == Some(p[i]) {
1590                matched = true;
1591            }
1592            i += 1;
1593        }
1594    }
1595    if i >= p.len() {
1596        return None;
1597    }
1598    // `ch` is None (empty text) or a `/` never matches a class.
1599    let ok = matches!(ch, Some(c) if c != '/') && (matched ^ negate);
1600    Some((ok, i + 1))
1601}