Parent directory

bash-glob.ts

3586 bytes
 1import { lstatSync, opendirSync, statSync } from "node:fs";
 2import { isAbsolute } from "node:path";
 3import type { BashWord } from "./bash";
 4
 5const MAX_ENTRIES = 2048;
 6const MAX_MATCHES = 256;
 7const MAX_COMPONENTS = 32;
 8
 9function componentPattern(pattern: string): { matcher: RegExp; active: boolean } | undefined {
10  let source = "";
11  let active = false;
12  for (let index = 0; index < pattern.length; index += 1) {
13    const character = pattern[index];
14    if (character === "\\") {
15      const literal = pattern[++index];
16      if (literal === undefined) return undefined;
17      source += literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
18    } else if (character === "*") {
19      if (pattern[index + 1] === "*") return undefined;
20      source += ".*";
21      active = true;
22    } else if (character === "?") {
23      source += ".";
24      active = true;
25    } else if (character === "[") {
26      active = true;
27      const end = pattern.indexOf("]", index + 1);
28      if (end === -1) return undefined;
29      const content = pattern.slice(index + 1, end);
30      if (!/^!?[A-Za-z0-9_.-]+$/.test(content)) return undefined;
31      source += `[${content.replace(/^!/, "^")}]`;
32      index = end;
33    } else source += character.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
34  }
35  try {
36    return { matcher: new RegExp(`^${source}$`, "su"), active };
37  } catch {
38    return undefined;
39  }
40}
41
42export function resolveBashGlob(word: BashWord, cwd: string): string[] | undefined {
43  if (word.unresolved) return undefined;
44  if (word.glob === undefined) return [word.text];
45  const components = word.glob.split("/");
46  if (components.length > MAX_COMPONENTS) return undefined;
47  let paths = [isAbsolute(word.glob) ? "/" : ""];
48  const absolutePath = (path: string) => (isAbsolute(path) ? path : `${cwd}/${path}`);
49  let entries = 0;
50  try {
51    for (const component of components) {
52      if (!component) continue;
53      const compiled = componentPattern(component);
54      if (!compiled) return undefined;
55      const { matcher, active } = compiled;
56      const next: string[] = [];
57      for (const path of paths) {
58        if (!active) {
59          const literal = component.replace(/\\(.)/g, "$1");
60          next.push(`${path}${path && !path.endsWith("/") ? "/" : ""}${literal}`);
61          continue;
62        }
63        const directory = opendirSync(absolutePath(path));
64        try {
65          for (let entry = directory.readSync(); entry; entry = directory.readSync()) {
66            if (++entries > MAX_ENTRIES) return undefined;
67            if (component.includes("[") && [...entry.name].some((character) => character.charCodeAt(0) > 127))
68              return undefined;
69            if (entry.name.startsWith(".") && !component.startsWith(".")) continue;
70            if (!matcher.test(entry.name)) continue;
71            next.push(`${path}${path && !path.endsWith("/") ? "/" : ""}${entry.name}`);
72            if (next.length > MAX_MATCHES) return undefined;
73          }
74        } finally {
75          directory.closeSync();
76        }
77      }
78      paths = next;
79    }
80    const matches = paths.filter((path) => {
81      try {
82        const metadata = word.text.endsWith("/") ? statSync(absolutePath(path)) : lstatSync(absolutePath(path));
83        return !word.text.endsWith("/") || metadata.isDirectory();
84      } catch (error) {
85        if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
86        throw error;
87      }
88    });
89    return matches.length ? matches.sort().map((path) => path + (word.text.endsWith("/") ? "/" : "")) : [word.text];
90  } catch {
91    return undefined;
92  }
93}