feat: unwrap 11 util namespaces to flat exports + barrel (#22739)

This commit is contained in:
Kit Langton
2026-04-15 23:15:58 -04:00
committed by GitHub
parent b0eae5e12f
commit 343a564183
188 changed files with 1182 additions and 1193 deletions

View File

@@ -1,5 +1,5 @@
import path from "path"
import { Process } from "./process"
import { Process } from "."
export async function extractZip(zipPath: string, destDir: string) {
if (process.platform === "win32") {

View File

@@ -1,19 +1,17 @@
export namespace Color {
export function isValidHex(hex?: string): hex is string {
if (!hex) return false
return /^#[0-9a-fA-F]{6}$/.test(hex)
}
export function hexToRgb(hex: string): { r: number; g: number; b: number } {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return { r, g, b }
}
export function hexToAnsiBold(hex?: string): string | undefined {
if (!isValidHex(hex)) return undefined
const { r, g, b } = hexToRgb(hex)
return `\x1b[38;2;${r};${g};${b}m\x1b[1m`
}
export function isValidHex(hex?: string): hex is string {
if (!hex) return false
return /^#[0-9a-fA-F]{6}$/.test(hex)
}
export function hexToRgb(hex: string): { r: number; g: number; b: number } {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return { r, g, b }
}
export function hexToAnsiBold(hex?: string): string | undefined {
if (!isValidHex(hex)) return undefined
const { r, g, b } = hexToRgb(hex)
return `\x1b[38;2;${r};${g};${b}m\x1b[1m`
}

View File

@@ -7,239 +7,237 @@ import { Readable } from "stream"
import { pipeline } from "stream/promises"
import { Glob } from "@opencode-ai/shared/util/glob"
export namespace Filesystem {
// Fast sync version for metadata checks
export async function exists(p: string): Promise<boolean> {
return existsSync(p)
}
// Fast sync version for metadata checks
export async function exists(p: string): Promise<boolean> {
return existsSync(p)
}
export async function isDir(p: string): Promise<boolean> {
try {
return statSync(p).isDirectory()
} catch {
return false
export async function isDir(p: string): Promise<boolean> {
try {
return statSync(p).isDirectory()
} catch {
return false
}
}
export function stat(p: string): ReturnType<typeof statSync> | undefined {
return statSync(p, { throwIfNoEntry: false }) ?? undefined
}
export async function statAsync(p: string): Promise<ReturnType<typeof statSync> | undefined> {
return statFile(p).catch((e) => {
if (isEnoent(e)) return undefined
throw e
})
}
export async function size(p: string): Promise<number> {
const s = stat(p)?.size ?? 0
return typeof s === "bigint" ? Number(s) : s
}
export async function readText(p: string): Promise<string> {
return readFile(p, "utf-8")
}
export async function readJson<T = any>(p: string): Promise<T> {
return JSON.parse(await readFile(p, "utf-8"))
}
export async function readBytes(p: string): Promise<Buffer> {
return readFile(p)
}
export async function readArrayBuffer(p: string): Promise<ArrayBuffer> {
const buf = await readFile(p)
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer
}
function isEnoent(e: unknown): e is { code: "ENOENT" } {
return typeof e === "object" && e !== null && "code" in e && (e as { code: string }).code === "ENOENT"
}
export async function write(p: string, content: string | Buffer | Uint8Array, mode?: number): Promise<void> {
try {
if (mode) {
await writeFile(p, content, { mode })
} else {
await writeFile(p, content)
}
}
export function stat(p: string): ReturnType<typeof statSync> | undefined {
return statSync(p, { throwIfNoEntry: false }) ?? undefined
}
export async function statAsync(p: string): Promise<ReturnType<typeof statSync> | undefined> {
return statFile(p).catch((e) => {
if (isEnoent(e)) return undefined
throw e
})
}
export async function size(p: string): Promise<number> {
const s = stat(p)?.size ?? 0
return typeof s === "bigint" ? Number(s) : s
}
export async function readText(p: string): Promise<string> {
return readFile(p, "utf-8")
}
export async function readJson<T = any>(p: string): Promise<T> {
return JSON.parse(await readFile(p, "utf-8"))
}
export async function readBytes(p: string): Promise<Buffer> {
return readFile(p)
}
export async function readArrayBuffer(p: string): Promise<ArrayBuffer> {
const buf = await readFile(p)
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer
}
function isEnoent(e: unknown): e is { code: "ENOENT" } {
return typeof e === "object" && e !== null && "code" in e && (e as { code: string }).code === "ENOENT"
}
export async function write(p: string, content: string | Buffer | Uint8Array, mode?: number): Promise<void> {
try {
} catch (e) {
if (isEnoent(e)) {
await mkdir(dirname(p), { recursive: true })
if (mode) {
await writeFile(p, content, { mode })
} else {
await writeFile(p, content)
}
} catch (e) {
if (isEnoent(e)) {
await mkdir(dirname(p), { recursive: true })
if (mode) {
await writeFile(p, content, { mode })
} else {
await writeFile(p, content)
}
return
}
throw e
return
}
}
export async function writeJson(p: string, data: unknown, mode?: number): Promise<void> {
return write(p, JSON.stringify(data, null, 2), mode)
}
export async function writeStream(
p: string,
stream: ReadableStream<Uint8Array> | Readable,
mode?: number,
): Promise<void> {
const dir = dirname(p)
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true })
}
const nodeStream = stream instanceof ReadableStream ? Readable.fromWeb(stream as any) : stream
const writeStream = createWriteStream(p)
await pipeline(nodeStream, writeStream)
if (mode) {
await chmod(p, mode)
}
}
export function mimeType(p: string): string {
return lookup(p) || "application/octet-stream"
}
/**
* On Windows, normalize a path to its canonical casing using the filesystem.
* This is needed because Windows paths are case-insensitive but LSP servers
* may return paths with different casing than what we send them.
*/
export function normalizePath(p: string): string {
if (process.platform !== "win32") return p
const resolved = win32.normalize(win32.resolve(windowsPath(p)))
try {
return realpathSync.native(resolved)
} catch {
return resolved
}
}
export function normalizePathPattern(p: string): string {
if (process.platform !== "win32") return p
if (p === "*") return p
const match = p.match(/^(.*)[\\/]\*$/)
if (!match) return normalizePath(p)
const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1]
return join(normalizePath(dir), "*")
}
// We cannot rely on path.resolve() here because git.exe may come from Git Bash, Cygwin, or MSYS2, so we need to translate these paths at the boundary.
// Also resolves symlinks so that callers using the result as a cache key
// always get the same canonical path for a given physical directory.
export function resolve(p: string): string {
const resolved = pathResolve(windowsPath(p))
try {
return normalizePath(realpathSync(resolved))
} catch (e) {
if (isEnoent(e)) return normalizePath(resolved)
throw e
}
}
export function windowsPath(p: string): string {
if (process.platform !== "win32") return p
return (
p
.replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
// Git Bash for Windows paths are typically /<drive>/...
.replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
// Cygwin git paths are typically /cygdrive/<drive>/...
.replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
// WSL paths are typically /mnt/<drive>/...
.replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
)
}
export function overlaps(a: string, b: string) {
const relA = relative(a, b)
const relB = relative(b, a)
return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
}
export function contains(parent: string, child: string) {
return !relative(parent, child).startsWith("..")
}
export async function findUp(
target: string,
start: string,
stop?: string,
options?: { rootFirst?: boolean },
): Promise<string[]>
export async function findUp(
target: string[],
start: string,
stop?: string,
options?: { rootFirst?: boolean },
): Promise<string[]>
export async function findUp(
target: string | string[],
start: string,
stop?: string,
options?: { rootFirst?: boolean },
) {
const dirs = [start]
let current = start
while (true) {
if (stop === current) break
const parent = dirname(current)
if (parent === current) break
dirs.push(parent)
current = parent
}
const targets = Array.isArray(target) ? target : [target]
const result = []
for (const dir of options?.rootFirst ? dirs.toReversed() : dirs) {
for (const item of targets) {
const search = join(dir, item)
if (await exists(search)) result.push(search)
}
}
return result
}
export async function* up(options: { targets: string[]; start: string; stop?: string }) {
const { targets, start, stop } = options
let current = start
while (true) {
for (const target of targets) {
const search = join(current, target)
if (await exists(search)) yield search
}
if (stop === current) break
const parent = dirname(current)
if (parent === current) break
current = parent
}
}
export async function globUp(pattern: string, start: string, stop?: string) {
let current = start
const result = []
while (true) {
try {
const matches = await Glob.scan(pattern, {
cwd: current,
absolute: true,
include: "file",
dot: true,
})
result.push(...matches)
} catch {
// Skip invalid glob patterns
}
if (stop === current) break
const parent = dirname(current)
if (parent === current) break
current = parent
}
return result
throw e
}
}
export async function writeJson(p: string, data: unknown, mode?: number): Promise<void> {
return write(p, JSON.stringify(data, null, 2), mode)
}
export async function writeStream(
p: string,
stream: ReadableStream<Uint8Array> | Readable,
mode?: number,
): Promise<void> {
const dir = dirname(p)
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true })
}
const nodeStream = stream instanceof ReadableStream ? Readable.fromWeb(stream as any) : stream
const writeStream = createWriteStream(p)
await pipeline(nodeStream, writeStream)
if (mode) {
await chmod(p, mode)
}
}
export function mimeType(p: string): string {
return lookup(p) || "application/octet-stream"
}
/**
* On Windows, normalize a path to its canonical casing using the filesystem.
* This is needed because Windows paths are case-insensitive but LSP servers
* may return paths with different casing than what we send them.
*/
export function normalizePath(p: string): string {
if (process.platform !== "win32") return p
const resolved = win32.normalize(win32.resolve(windowsPath(p)))
try {
return realpathSync.native(resolved)
} catch {
return resolved
}
}
export function normalizePathPattern(p: string): string {
if (process.platform !== "win32") return p
if (p === "*") return p
const match = p.match(/^(.*)[\\/]\*$/)
if (!match) return normalizePath(p)
const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1]
return join(normalizePath(dir), "*")
}
// We cannot rely on path.resolve() here because git.exe may come from Git Bash, Cygwin, or MSYS2, so we need to translate these paths at the boundary.
// Also resolves symlinks so that callers using the result as a cache key
// always get the same canonical path for a given physical directory.
export function resolve(p: string): string {
const resolved = pathResolve(windowsPath(p))
try {
return normalizePath(realpathSync(resolved))
} catch (e) {
if (isEnoent(e)) return normalizePath(resolved)
throw e
}
}
export function windowsPath(p: string): string {
if (process.platform !== "win32") return p
return (
p
.replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
// Git Bash for Windows paths are typically /<drive>/...
.replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
// Cygwin git paths are typically /cygdrive/<drive>/...
.replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
// WSL paths are typically /mnt/<drive>/...
.replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
)
}
export function overlaps(a: string, b: string) {
const relA = relative(a, b)
const relB = relative(b, a)
return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
}
export function contains(parent: string, child: string) {
return !relative(parent, child).startsWith("..")
}
export async function findUp(
target: string,
start: string,
stop?: string,
options?: { rootFirst?: boolean },
): Promise<string[]>
export async function findUp(
target: string[],
start: string,
stop?: string,
options?: { rootFirst?: boolean },
): Promise<string[]>
export async function findUp(
target: string | string[],
start: string,
stop?: string,
options?: { rootFirst?: boolean },
) {
const dirs = [start]
let current = start
while (true) {
if (stop === current) break
const parent = dirname(current)
if (parent === current) break
dirs.push(parent)
current = parent
}
const targets = Array.isArray(target) ? target : [target]
const result = []
for (const dir of options?.rootFirst ? dirs.toReversed() : dirs) {
for (const item of targets) {
const search = join(dir, item)
if (await exists(search)) result.push(search)
}
}
return result
}
export async function* up(options: { targets: string[]; start: string; stop?: string }) {
const { targets, start, stop } = options
let current = start
while (true) {
for (const target of targets) {
const search = join(current, target)
if (await exists(search)) yield search
}
if (stop === current) break
const parent = dirname(current)
if (parent === current) break
current = parent
}
}
export async function globUp(pattern: string, start: string, stop?: string) {
let current = start
const result = []
while (true) {
try {
const matches = await Glob.scan(pattern, {
cwd: current,
absolute: true,
include: "file",
dot: true,
})
result.push(...matches)
} catch {
// Skip invalid glob patterns
}
if (stop === current) break
const parent = dirname(current)
if (parent === current) break
current = parent
}
return result
}

View File

@@ -1 +1,12 @@
export * as Archive from "./archive"
export * as Color from "./color"
export * as Filesystem from "./filesystem"
export * as Keybind from "./keybind"
export * as LocalContext from "./local-context"
export * as Locale from "./locale"
export * as Lock from "./lock"
export * as Log from "./log"
export * as Process from "./process"
export * as Rpc from "./rpc"
export * as Token from "./token"
export * as Wildcard from "./wildcard"

View File

@@ -1,103 +1,101 @@
import { isDeepEqual } from "remeda"
import type { ParsedKey } from "@opentui/core"
export namespace Keybind {
/**
* Keybind info derived from OpenTUI's ParsedKey with our custom `leader` field.
* This ensures type compatibility and catches missing fields at compile time.
*/
export type Info = Pick<ParsedKey, "name" | "ctrl" | "meta" | "shift" | "super"> & {
leader: boolean // our custom field
}
/**
* Keybind info derived from OpenTUI's ParsedKey with our custom `leader` field.
* This ensures type compatibility and catches missing fields at compile time.
*/
export type Info = Pick<ParsedKey, "name" | "ctrl" | "meta" | "shift" | "super"> & {
leader: boolean // our custom field
}
export function match(a: Info | undefined, b: Info): boolean {
if (!a) return false
const normalizedA = { ...a, super: a.super ?? false }
const normalizedB = { ...b, super: b.super ?? false }
return isDeepEqual(normalizedA, normalizedB)
}
export function match(a: Info | undefined, b: Info): boolean {
if (!a) return false
const normalizedA = { ...a, super: a.super ?? false }
const normalizedB = { ...b, super: b.super ?? false }
return isDeepEqual(normalizedA, normalizedB)
}
/**
* Convert OpenTUI's ParsedKey to our Keybind.Info format.
* This helper ensures all required fields are present and avoids manual object creation.
*/
export function fromParsedKey(key: ParsedKey, leader = false): Info {
return {
name: key.name === " " ? "space" : key.name,
ctrl: key.ctrl,
meta: key.meta,
shift: key.shift,
super: key.super ?? false,
leader,
}
}
export function toString(info: Info | undefined): string {
if (!info) return ""
const parts: string[] = []
if (info.ctrl) parts.push("ctrl")
if (info.meta) parts.push("alt")
if (info.super) parts.push("super")
if (info.shift) parts.push("shift")
if (info.name) {
if (info.name === "delete") parts.push("del")
else parts.push(info.name)
}
let result = parts.join("+")
if (info.leader) {
result = result ? `<leader> ${result}` : `<leader>`
}
return result
}
export function parse(key: string): Info[] {
if (key === "none") return []
return key.split(",").map((combo) => {
// Handle <leader> syntax by replacing with leader+
const normalized = combo.replace(/<leader>/g, "leader+")
const parts = normalized.toLowerCase().split("+")
const info: Info = {
ctrl: false,
meta: false,
shift: false,
leader: false,
name: "",
}
for (const part of parts) {
switch (part) {
case "ctrl":
info.ctrl = true
break
case "alt":
case "meta":
case "option":
info.meta = true
break
case "super":
info.super = true
break
case "shift":
info.shift = true
break
case "leader":
info.leader = true
break
case "esc":
info.name = "escape"
break
default:
info.name = part
break
}
}
return info
})
/**
* Convert OpenTUI's ParsedKey to our Keybind.Info format.
* This helper ensures all required fields are present and avoids manual object creation.
*/
export function fromParsedKey(key: ParsedKey, leader = false): Info {
return {
name: key.name === " " ? "space" : key.name,
ctrl: key.ctrl,
meta: key.meta,
shift: key.shift,
super: key.super ?? false,
leader,
}
}
export function toString(info: Info | undefined): string {
if (!info) return ""
const parts: string[] = []
if (info.ctrl) parts.push("ctrl")
if (info.meta) parts.push("alt")
if (info.super) parts.push("super")
if (info.shift) parts.push("shift")
if (info.name) {
if (info.name === "delete") parts.push("del")
else parts.push(info.name)
}
let result = parts.join("+")
if (info.leader) {
result = result ? `<leader> ${result}` : `<leader>`
}
return result
}
export function parse(key: string): Info[] {
if (key === "none") return []
return key.split(",").map((combo) => {
// Handle <leader> syntax by replacing with leader+
const normalized = combo.replace(/<leader>/g, "leader+")
const parts = normalized.toLowerCase().split("+")
const info: Info = {
ctrl: false,
meta: false,
shift: false,
leader: false,
name: "",
}
for (const part of parts) {
switch (part) {
case "ctrl":
info.ctrl = true
break
case "alt":
case "meta":
case "option":
info.meta = true
break
case "super":
info.super = true
break
case "shift":
info.shift = true
break
case "leader":
info.leader = true
break
case "esc":
info.name = "escape"
break
default:
info.name = part
break
}
}
return info
})
}

View File

@@ -1,25 +1,23 @@
import { AsyncLocalStorage } from "async_hooks"
export namespace LocalContext {
export class NotFound extends Error {
constructor(public override readonly name: string) {
super(`No context found for ${name}`)
}
}
export function create<T>(name: string) {
const storage = new AsyncLocalStorage<T>()
return {
use() {
const result = storage.getStore()
if (!result) {
throw new NotFound(name)
}
return result
},
provide<R>(value: T, fn: () => R) {
return storage.run(value, fn)
},
}
export class NotFound extends Error {
constructor(public override readonly name: string) {
super(`No context found for ${name}`)
}
}
export function create<T>(name: string) {
const storage = new AsyncLocalStorage<T>()
return {
use() {
const result = storage.getStore()
if (!result) {
throw new NotFound(name)
}
return result
},
provide<R>(value: T, fn: () => R) {
return storage.run(value, fn)
},
}
}

View File

@@ -1,81 +1,79 @@
export namespace Locale {
export function titlecase(str: string) {
return str.replace(/\b\w/g, (c) => c.toUpperCase())
}
export function titlecase(str: string) {
return str.replace(/\b\w/g, (c) => c.toUpperCase())
}
export function time(input: number): string {
const date = new Date(input)
return date.toLocaleTimeString(undefined, { timeStyle: "short" })
}
export function time(input: number): string {
const date = new Date(input)
return date.toLocaleTimeString(undefined, { timeStyle: "short" })
}
export function datetime(input: number): string {
const date = new Date(input)
const localTime = time(input)
const localDate = date.toLocaleDateString()
return `${localTime} · ${localDate}`
}
export function datetime(input: number): string {
const date = new Date(input)
const localTime = time(input)
const localDate = date.toLocaleDateString()
return `${localTime} · ${localDate}`
}
export function todayTimeOrDateTime(input: number): string {
const date = new Date(input)
const now = new Date()
const isToday =
date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate()
export function todayTimeOrDateTime(input: number): string {
const date = new Date(input)
const now = new Date()
const isToday =
date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate()
if (isToday) {
return time(input)
} else {
return datetime(input)
}
}
export function number(num: number): string {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + "M"
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + "K"
}
return num.toString()
}
export function duration(input: number) {
if (input < 1000) {
return `${input}ms`
}
if (input < 60000) {
return `${(input / 1000).toFixed(1)}s`
}
if (input < 3600000) {
const minutes = Math.floor(input / 60000)
const seconds = Math.floor((input % 60000) / 1000)
return `${minutes}m ${seconds}s`
}
if (input < 86400000) {
const hours = Math.floor(input / 3600000)
const minutes = Math.floor((input % 3600000) / 60000)
return `${hours}h ${minutes}m`
}
const hours = Math.floor(input / 3600000)
const days = Math.floor((input % 3600000) / 86400000)
return `${days}d ${hours}h`
}
export function truncate(str: string, len: number): string {
if (str.length <= len) return str
return str.slice(0, len - 1) + "…"
}
export function truncateMiddle(str: string, maxLength: number = 35): string {
if (str.length <= maxLength) return str
const ellipsis = "…"
const keepStart = Math.ceil((maxLength - ellipsis.length) / 2)
const keepEnd = Math.floor((maxLength - ellipsis.length) / 2)
return str.slice(0, keepStart) + ellipsis + str.slice(-keepEnd)
}
export function pluralize(count: number, singular: string, plural: string): string {
const template = count === 1 ? singular : plural
return template.replace("{}", count.toString())
if (isToday) {
return time(input)
} else {
return datetime(input)
}
}
export function number(num: number): string {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + "M"
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + "K"
}
return num.toString()
}
export function duration(input: number) {
if (input < 1000) {
return `${input}ms`
}
if (input < 60000) {
return `${(input / 1000).toFixed(1)}s`
}
if (input < 3600000) {
const minutes = Math.floor(input / 60000)
const seconds = Math.floor((input % 60000) / 1000)
return `${minutes}m ${seconds}s`
}
if (input < 86400000) {
const hours = Math.floor(input / 3600000)
const minutes = Math.floor((input % 3600000) / 60000)
return `${hours}h ${minutes}m`
}
const hours = Math.floor(input / 3600000)
const days = Math.floor((input % 3600000) / 86400000)
return `${days}d ${hours}h`
}
export function truncate(str: string, len: number): string {
if (str.length <= len) return str
return str.slice(0, len - 1) + "…"
}
export function truncateMiddle(str: string, maxLength: number = 35): string {
if (str.length <= maxLength) return str
const ellipsis = "…"
const keepStart = Math.ceil((maxLength - ellipsis.length) / 2)
const keepEnd = Math.floor((maxLength - ellipsis.length) / 2)
return str.slice(0, keepStart) + ellipsis + str.slice(-keepEnd)
}
export function pluralize(count: number, singular: string, plural: string): string {
const template = count === 1 ? singular : plural
return template.replace("{}", count.toString())
}

View File

@@ -1,54 +1,62 @@
export namespace Lock {
const locks = new Map<
string,
{
readers: number
writer: boolean
waitingReaders: (() => void)[]
waitingWriters: (() => void)[]
}
>()
const locks = new Map<
string,
{
readers: number
writer: boolean
waitingReaders: (() => void)[]
waitingWriters: (() => void)[]
}
>()
function get(key: string) {
if (!locks.has(key)) {
locks.set(key, {
readers: 0,
writer: false,
waitingReaders: [],
waitingWriters: [],
function get(key: string) {
if (!locks.has(key)) {
locks.set(key, {
readers: 0,
writer: false,
waitingReaders: [],
waitingWriters: [],
})
}
return locks.get(key)!
}
function process(key: string) {
const lock = locks.get(key)
if (!lock || lock.writer || lock.readers > 0) return
// Prioritize writers to prevent starvation
if (lock.waitingWriters.length > 0) {
const nextWriter = lock.waitingWriters.shift()!
nextWriter()
return
}
// Wake up all waiting readers
while (lock.waitingReaders.length > 0) {
const nextReader = lock.waitingReaders.shift()!
nextReader()
}
// Clean up empty locks
if (lock.readers === 0 && !lock.writer && lock.waitingReaders.length === 0 && lock.waitingWriters.length === 0) {
locks.delete(key)
}
}
export async function read(key: string): Promise<Disposable> {
const lock = get(key)
return new Promise((resolve) => {
if (!lock.writer && lock.waitingWriters.length === 0) {
lock.readers++
resolve({
[Symbol.dispose]: () => {
lock.readers--
process(key)
},
})
}
return locks.get(key)!
}
function process(key: string) {
const lock = locks.get(key)
if (!lock || lock.writer || lock.readers > 0) return
// Prioritize writers to prevent starvation
if (lock.waitingWriters.length > 0) {
const nextWriter = lock.waitingWriters.shift()!
nextWriter()
return
}
// Wake up all waiting readers
while (lock.waitingReaders.length > 0) {
const nextReader = lock.waitingReaders.shift()!
nextReader()
}
// Clean up empty locks
if (lock.readers === 0 && !lock.writer && lock.waitingReaders.length === 0 && lock.waitingWriters.length === 0) {
locks.delete(key)
}
}
export async function read(key: string): Promise<Disposable> {
const lock = get(key)
return new Promise((resolve) => {
if (!lock.writer && lock.waitingWriters.length === 0) {
} else {
lock.waitingReaders.push(() => {
lock.readers++
resolve({
[Symbol.dispose]: () => {
@@ -56,25 +64,25 @@ export namespace Lock {
process(key)
},
})
} else {
lock.waitingReaders.push(() => {
lock.readers++
resolve({
[Symbol.dispose]: () => {
lock.readers--
process(key)
},
})
})
}
})
}
})
}
})
}
export async function write(key: string): Promise<Disposable> {
const lock = get(key)
export async function write(key: string): Promise<Disposable> {
const lock = get(key)
return new Promise((resolve) => {
if (!lock.writer && lock.readers === 0) {
return new Promise((resolve) => {
if (!lock.writer && lock.readers === 0) {
lock.writer = true
resolve({
[Symbol.dispose]: () => {
lock.writer = false
process(key)
},
})
} else {
lock.waitingWriters.push(() => {
lock.writer = true
resolve({
[Symbol.dispose]: () => {
@@ -82,17 +90,7 @@ export namespace Lock {
process(key)
},
})
} else {
lock.waitingWriters.push(() => {
lock.writer = true
resolve({
[Symbol.dispose]: () => {
lock.writer = false
process(key)
},
})
})
}
})
}
})
}
})
}

View File

@@ -5,183 +5,181 @@ import { Global } from "../global"
import z from "zod"
import { Glob } from "@opencode-ai/shared/util/glob"
export namespace Log {
export const Level = z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).meta({ ref: "LogLevel", description: "Log level" })
export type Level = z.infer<typeof Level>
export const Level = z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).meta({ ref: "LogLevel", description: "Log level" })
export type Level = z.infer<typeof Level>
const levelPriority: Record<Level, number> = {
DEBUG: 0,
INFO: 1,
WARN: 2,
ERROR: 3,
}
const keep = 10
const levelPriority: Record<Level, number> = {
DEBUG: 0,
INFO: 1,
WARN: 2,
ERROR: 3,
}
const keep = 10
let level: Level = "INFO"
let level: Level = "INFO"
function shouldLog(input: Level): boolean {
return levelPriority[input] >= levelPriority[level]
}
function shouldLog(input: Level): boolean {
return levelPriority[input] >= levelPriority[level]
}
export type Logger = {
debug(message?: any, extra?: Record<string, any>): void
info(message?: any, extra?: Record<string, any>): void
error(message?: any, extra?: Record<string, any>): void
warn(message?: any, extra?: Record<string, any>): void
tag(key: string, value: string): Logger
clone(): Logger
time(
message: string,
extra?: Record<string, any>,
): {
stop(): void
[Symbol.dispose](): void
}
}
const loggers = new Map<string, Logger>()
export const Default = create({ service: "default" })
export interface Options {
print: boolean
dev?: boolean
level?: Level
}
let logpath = ""
export function file() {
return logpath
}
let write = (msg: any) => {
process.stderr.write(msg)
return msg.length
}
export async function init(options: Options) {
if (options.level) level = options.level
cleanup(Global.Path.log)
if (options.print) return
logpath = path.join(
Global.Path.log,
options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log",
)
await fs.truncate(logpath).catch(() => {})
const stream = createWriteStream(logpath, { flags: "a" })
write = async (msg: any) => {
return new Promise((resolve, reject) => {
stream.write(msg, (err) => {
if (err) reject(err)
else resolve(msg.length)
})
})
}
}
async function cleanup(dir: string) {
const files = (
await Glob.scan("????-??-??T??????.log", {
cwd: dir,
absolute: false,
include: "file",
}).catch(() => [])
)
.filter((file) => path.basename(file) === file)
.sort()
if (files.length <= keep) return
const doomed = files.slice(0, -keep)
await Promise.all(doomed.map((file) => fs.unlink(path.join(dir, file)).catch(() => {})))
}
function formatError(error: Error, depth = 0): string {
const result = error.message
return error.cause instanceof Error && depth < 10
? result + " Caused by: " + formatError(error.cause, depth + 1)
: result
}
let last = Date.now()
export function create(tags?: Record<string, any>) {
tags = tags || {}
const service = tags["service"]
if (service && typeof service === "string") {
const cached = loggers.get(service)
if (cached) {
return cached
}
}
function build(message: any, extra?: Record<string, any>) {
const prefix = Object.entries({
...tags,
...extra,
})
.filter(([_, value]) => value !== undefined && value !== null)
.map(([key, value]) => {
const prefix = `${key}=`
if (value instanceof Error) return prefix + formatError(value)
if (typeof value === "object") return prefix + JSON.stringify(value)
return prefix + value
})
.join(" ")
const next = new Date()
const diff = next.getTime() - last
last = next.getTime()
return [next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message].filter(Boolean).join(" ") + "\n"
}
const result: Logger = {
debug(message?: any, extra?: Record<string, any>) {
if (shouldLog("DEBUG")) {
write("DEBUG " + build(message, extra))
}
},
info(message?: any, extra?: Record<string, any>) {
if (shouldLog("INFO")) {
write("INFO " + build(message, extra))
}
},
error(message?: any, extra?: Record<string, any>) {
if (shouldLog("ERROR")) {
write("ERROR " + build(message, extra))
}
},
warn(message?: any, extra?: Record<string, any>) {
if (shouldLog("WARN")) {
write("WARN " + build(message, extra))
}
},
tag(key: string, value: string) {
if (tags) tags[key] = value
return result
},
clone() {
return Log.create({ ...tags })
},
time(message: string, extra?: Record<string, any>) {
const now = Date.now()
result.info(message, { status: "started", ...extra })
function stop() {
result.info(message, {
status: "completed",
duration: Date.now() - now,
...extra,
})
}
return {
stop,
[Symbol.dispose]() {
stop()
},
}
},
}
if (service && typeof service === "string") {
loggers.set(service, result)
}
return result
export type Logger = {
debug(message?: any, extra?: Record<string, any>): void
info(message?: any, extra?: Record<string, any>): void
error(message?: any, extra?: Record<string, any>): void
warn(message?: any, extra?: Record<string, any>): void
tag(key: string, value: string): Logger
clone(): Logger
time(
message: string,
extra?: Record<string, any>,
): {
stop(): void
[Symbol.dispose](): void
}
}
const loggers = new Map<string, Logger>()
export const Default = create({ service: "default" })
export interface Options {
print: boolean
dev?: boolean
level?: Level
}
let logpath = ""
export function file() {
return logpath
}
let write = (msg: any) => {
process.stderr.write(msg)
return msg.length
}
export async function init(options: Options) {
if (options.level) level = options.level
cleanup(Global.Path.log)
if (options.print) return
logpath = path.join(
Global.Path.log,
options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log",
)
await fs.truncate(logpath).catch(() => {})
const stream = createWriteStream(logpath, { flags: "a" })
write = async (msg: any) => {
return new Promise((resolve, reject) => {
stream.write(msg, (err) => {
if (err) reject(err)
else resolve(msg.length)
})
})
}
}
async function cleanup(dir: string) {
const files = (
await Glob.scan("????-??-??T??????.log", {
cwd: dir,
absolute: false,
include: "file",
}).catch(() => [])
)
.filter((file) => path.basename(file) === file)
.sort()
if (files.length <= keep) return
const doomed = files.slice(0, -keep)
await Promise.all(doomed.map((file) => fs.unlink(path.join(dir, file)).catch(() => {})))
}
function formatError(error: Error, depth = 0): string {
const result = error.message
return error.cause instanceof Error && depth < 10
? result + " Caused by: " + formatError(error.cause, depth + 1)
: result
}
let last = Date.now()
export function create(tags?: Record<string, any>) {
tags = tags || {}
const service = tags["service"]
if (service && typeof service === "string") {
const cached = loggers.get(service)
if (cached) {
return cached
}
}
function build(message: any, extra?: Record<string, any>) {
const prefix = Object.entries({
...tags,
...extra,
})
.filter(([_, value]) => value !== undefined && value !== null)
.map(([key, value]) => {
const prefix = `${key}=`
if (value instanceof Error) return prefix + formatError(value)
if (typeof value === "object") return prefix + JSON.stringify(value)
return prefix + value
})
.join(" ")
const next = new Date()
const diff = next.getTime() - last
last = next.getTime()
return [next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message].filter(Boolean).join(" ") + "\n"
}
const result: Logger = {
debug(message?: any, extra?: Record<string, any>) {
if (shouldLog("DEBUG")) {
write("DEBUG " + build(message, extra))
}
},
info(message?: any, extra?: Record<string, any>) {
if (shouldLog("INFO")) {
write("INFO " + build(message, extra))
}
},
error(message?: any, extra?: Record<string, any>) {
if (shouldLog("ERROR")) {
write("ERROR " + build(message, extra))
}
},
warn(message?: any, extra?: Record<string, any>) {
if (shouldLog("WARN")) {
write("WARN " + build(message, extra))
}
},
tag(key: string, value: string) {
if (tags) tags[key] = value
return result
},
clone() {
return create({ ...tags })
},
time(message: string, extra?: Record<string, any>) {
const now = Date.now()
result.info(message, { status: "started", ...extra })
function stop() {
result.info(message, {
status: "completed",
duration: Date.now() - now,
...extra,
})
}
return {
stop,
[Symbol.dispose]() {
stop()
},
}
},
}
if (service && typeof service === "string") {
loggers.set(service, result)
}
return result
}

View File

@@ -3,174 +3,172 @@ import launch from "cross-spawn"
import { buffer } from "node:stream/consumers"
import { errorMessage } from "./error"
export namespace Process {
export type Stdio = "inherit" | "pipe" | "ignore"
export type Shell = boolean | string
export type Stdio = "inherit" | "pipe" | "ignore"
export type Shell = boolean | string
export interface Options {
cwd?: string
env?: NodeJS.ProcessEnv | null
stdin?: Stdio
stdout?: Stdio
stderr?: Stdio
shell?: Shell
abort?: AbortSignal
kill?: NodeJS.Signals | number
timeout?: number
}
export interface Options {
cwd?: string
env?: NodeJS.ProcessEnv | null
stdin?: Stdio
stdout?: Stdio
stderr?: Stdio
shell?: Shell
abort?: AbortSignal
kill?: NodeJS.Signals | number
timeout?: number
}
export interface RunOptions extends Omit<Options, "stdout" | "stderr"> {
nothrow?: boolean
}
export interface RunOptions extends Omit<Options, "stdout" | "stderr"> {
nothrow?: boolean
}
export interface Result {
code: number
stdout: Buffer
stderr: Buffer
}
export interface Result {
code: number
stdout: Buffer
stderr: Buffer
}
export interface TextResult extends Result {
text: string
}
export interface TextResult extends Result {
text: string
}
export class RunFailedError extends Error {
readonly cmd: string[]
readonly code: number
readonly stdout: Buffer
readonly stderr: Buffer
export class RunFailedError extends Error {
readonly cmd: string[]
readonly code: number
readonly stdout: Buffer
readonly stderr: Buffer
constructor(cmd: string[], code: number, stdout: Buffer, stderr: Buffer) {
const text = stderr.toString().trim()
super(
text
? `Command failed with code ${code}: ${cmd.join(" ")}\n${text}`
: `Command failed with code ${code}: ${cmd.join(" ")}`,
)
this.name = "ProcessRunFailedError"
this.cmd = [...cmd]
this.code = code
this.stdout = stdout
this.stderr = stderr
}
}
export type Child = ChildProcess & { exited: Promise<number> }
export function spawn(cmd: string[], opts: Options = {}): Child {
if (cmd.length === 0) throw new Error("Command is required")
opts.abort?.throwIfAborted()
const proc = launch(cmd[0], cmd.slice(1), {
cwd: opts.cwd,
shell: opts.shell,
env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined,
stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"],
windowsHide: process.platform === "win32",
})
let closed = false
let timer: ReturnType<typeof setTimeout> | undefined
const abort = () => {
if (closed) return
if (proc.exitCode !== null || proc.signalCode !== null) return
closed = true
proc.kill(opts.kill ?? "SIGTERM")
const ms = opts.timeout ?? 5_000
if (ms <= 0) return
timer = setTimeout(() => proc.kill("SIGKILL"), ms)
}
const exited = new Promise<number>((resolve, reject) => {
const done = () => {
opts.abort?.removeEventListener("abort", abort)
if (timer) clearTimeout(timer)
}
proc.once("exit", (code, signal) => {
done()
resolve(code ?? (signal ? 1 : 0))
})
proc.once("error", (error) => {
done()
reject(error)
})
})
void exited.catch(() => undefined)
if (opts.abort) {
opts.abort.addEventListener("abort", abort, { once: true })
if (opts.abort.aborted) abort()
}
const child = proc as Child
child.exited = exited
return child
}
export async function run(cmd: string[], opts: RunOptions = {}): Promise<Result> {
const proc = spawn(cmd, {
cwd: opts.cwd,
env: opts.env,
stdin: opts.stdin,
shell: opts.shell,
abort: opts.abort,
kill: opts.kill,
timeout: opts.timeout,
stdout: "pipe",
stderr: "pipe",
})
if (!proc.stdout || !proc.stderr) throw new Error("Process output not available")
const out = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
.then(([code, stdout, stderr]) => ({
code,
stdout,
stderr,
}))
.catch((err: unknown) => {
if (!opts.nothrow) throw err
return {
code: 1,
stdout: Buffer.alloc(0),
stderr: Buffer.from(errorMessage(err)),
}
})
if (out.code === 0 || opts.nothrow) return out
throw new RunFailedError(cmd, out.code, out.stdout, out.stderr)
}
// Duplicated in `packages/sdk/js/src/process.ts` because the SDK cannot import
// `opencode` without creating a cycle. Keep both copies in sync.
export async function stop(proc: ChildProcess) {
if (proc.exitCode !== null || proc.signalCode !== null) return
if (process.platform !== "win32" || !proc.pid) {
proc.kill()
return
}
const out = await run(["taskkill", "/pid", String(proc.pid), "/T", "/F"], {
nothrow: true,
})
if (out.code === 0) return
proc.kill()
}
export async function text(cmd: string[], opts: RunOptions = {}): Promise<TextResult> {
const out = await run(cmd, opts)
return {
...out,
text: out.stdout.toString(),
}
}
export async function lines(cmd: string[], opts: RunOptions = {}): Promise<string[]> {
return (await text(cmd, opts)).text.split(/\r?\n/).filter(Boolean)
constructor(cmd: string[], code: number, stdout: Buffer, stderr: Buffer) {
const text = stderr.toString().trim()
super(
text
? `Command failed with code ${code}: ${cmd.join(" ")}\n${text}`
: `Command failed with code ${code}: ${cmd.join(" ")}`,
)
this.name = "ProcessRunFailedError"
this.cmd = [...cmd]
this.code = code
this.stdout = stdout
this.stderr = stderr
}
}
export type Child = ChildProcess & { exited: Promise<number> }
export function spawn(cmd: string[], opts: Options = {}): Child {
if (cmd.length === 0) throw new Error("Command is required")
opts.abort?.throwIfAborted()
const proc = launch(cmd[0], cmd.slice(1), {
cwd: opts.cwd,
shell: opts.shell,
env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined,
stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"],
windowsHide: process.platform === "win32",
})
let closed = false
let timer: ReturnType<typeof setTimeout> | undefined
const abort = () => {
if (closed) return
if (proc.exitCode !== null || proc.signalCode !== null) return
closed = true
proc.kill(opts.kill ?? "SIGTERM")
const ms = opts.timeout ?? 5_000
if (ms <= 0) return
timer = setTimeout(() => proc.kill("SIGKILL"), ms)
}
const exited = new Promise<number>((resolve, reject) => {
const done = () => {
opts.abort?.removeEventListener("abort", abort)
if (timer) clearTimeout(timer)
}
proc.once("exit", (code, signal) => {
done()
resolve(code ?? (signal ? 1 : 0))
})
proc.once("error", (error) => {
done()
reject(error)
})
})
void exited.catch(() => undefined)
if (opts.abort) {
opts.abort.addEventListener("abort", abort, { once: true })
if (opts.abort.aborted) abort()
}
const child = proc as Child
child.exited = exited
return child
}
export async function run(cmd: string[], opts: RunOptions = {}): Promise<Result> {
const proc = spawn(cmd, {
cwd: opts.cwd,
env: opts.env,
stdin: opts.stdin,
shell: opts.shell,
abort: opts.abort,
kill: opts.kill,
timeout: opts.timeout,
stdout: "pipe",
stderr: "pipe",
})
if (!proc.stdout || !proc.stderr) throw new Error("Process output not available")
const out = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
.then(([code, stdout, stderr]) => ({
code,
stdout,
stderr,
}))
.catch((err: unknown) => {
if (!opts.nothrow) throw err
return {
code: 1,
stdout: Buffer.alloc(0),
stderr: Buffer.from(errorMessage(err)),
}
})
if (out.code === 0 || opts.nothrow) return out
throw new RunFailedError(cmd, out.code, out.stdout, out.stderr)
}
// Duplicated in `packages/sdk/js/src/process.ts` because the SDK cannot import
// `opencode` without creating a cycle. Keep both copies in sync.
export async function stop(proc: ChildProcess) {
if (proc.exitCode !== null || proc.signalCode !== null) return
if (process.platform !== "win32" || !proc.pid) {
proc.kill()
return
}
const out = await run(["taskkill", "/pid", String(proc.pid), "/T", "/F"], {
nothrow: true,
})
if (out.code === 0) return
proc.kill()
}
export async function text(cmd: string[], opts: RunOptions = {}): Promise<TextResult> {
const out = await run(cmd, opts)
return {
...out,
text: out.stdout.toString(),
}
}
export async function lines(cmd: string[], opts: RunOptions = {}): Promise<string[]> {
return (await text(cmd, opts)).text.split(/\r?\n/).filter(Boolean)
}

View File

@@ -1,66 +1,64 @@
export namespace Rpc {
type Definition = {
[method: string]: (input: any) => any
}
type Definition = {
[method: string]: (input: any) => any
}
export function listen(rpc: Definition) {
onmessage = async (evt) => {
const parsed = JSON.parse(evt.data)
if (parsed.type === "rpc.request") {
const result = await rpc[parsed.method](parsed.input)
postMessage(JSON.stringify({ type: "rpc.result", result, id: parsed.id }))
}
}
}
export function emit(event: string, data: unknown) {
postMessage(JSON.stringify({ type: "rpc.event", event, data }))
}
export function client<T extends Definition>(target: {
postMessage: (data: string) => void | null
onmessage: ((this: Worker, ev: MessageEvent<any>) => any) | null
}) {
const pending = new Map<number, (result: any) => void>()
const listeners = new Map<string, Set<(data: any) => void>>()
let id = 0
target.onmessage = async (evt) => {
const parsed = JSON.parse(evt.data)
if (parsed.type === "rpc.result") {
const resolve = pending.get(parsed.id)
if (resolve) {
resolve(parsed.result)
pending.delete(parsed.id)
}
}
if (parsed.type === "rpc.event") {
const handlers = listeners.get(parsed.event)
if (handlers) {
for (const handler of handlers) {
handler(parsed.data)
}
}
}
}
return {
call<Method extends keyof T>(method: Method, input: Parameters<T[Method]>[0]): Promise<ReturnType<T[Method]>> {
const requestId = id++
return new Promise((resolve) => {
pending.set(requestId, resolve)
target.postMessage(JSON.stringify({ type: "rpc.request", method, input, id: requestId }))
})
},
on<Data>(event: string, handler: (data: Data) => void) {
let handlers = listeners.get(event)
if (!handlers) {
handlers = new Set()
listeners.set(event, handlers)
}
handlers.add(handler)
return () => {
handlers!.delete(handler)
}
},
export function listen(rpc: Definition) {
onmessage = async (evt) => {
const parsed = JSON.parse(evt.data)
if (parsed.type === "rpc.request") {
const result = await rpc[parsed.method](parsed.input)
postMessage(JSON.stringify({ type: "rpc.result", result, id: parsed.id }))
}
}
}
export function emit(event: string, data: unknown) {
postMessage(JSON.stringify({ type: "rpc.event", event, data }))
}
export function client<T extends Definition>(target: {
postMessage: (data: string) => void | null
onmessage: ((this: Worker, ev: MessageEvent<any>) => any) | null
}) {
const pending = new Map<number, (result: any) => void>()
const listeners = new Map<string, Set<(data: any) => void>>()
let id = 0
target.onmessage = async (evt) => {
const parsed = JSON.parse(evt.data)
if (parsed.type === "rpc.result") {
const resolve = pending.get(parsed.id)
if (resolve) {
resolve(parsed.result)
pending.delete(parsed.id)
}
}
if (parsed.type === "rpc.event") {
const handlers = listeners.get(parsed.event)
if (handlers) {
for (const handler of handlers) {
handler(parsed.data)
}
}
}
}
return {
call<Method extends keyof T>(method: Method, input: Parameters<T[Method]>[0]): Promise<ReturnType<T[Method]>> {
const requestId = id++
return new Promise((resolve) => {
pending.set(requestId, resolve)
target.postMessage(JSON.stringify({ type: "rpc.request", method, input, id: requestId }))
})
},
on<Data>(event: string, handler: (data: Data) => void) {
let handlers = listeners.get(event)
if (!handlers) {
handlers = new Set()
listeners.set(event, handlers)
}
handlers.add(handler)
return () => {
handlers!.delete(handler)
}
},
}
}

View File

@@ -1,7 +1,5 @@
export namespace Token {
const CHARS_PER_TOKEN = 4
const CHARS_PER_TOKEN = 4
export function estimate(input: string) {
return Math.max(0, Math.round((input || "").length / CHARS_PER_TOKEN))
}
export function estimate(input: string) {
return Math.max(0, Math.round((input || "").length / CHARS_PER_TOKEN))
}

View File

@@ -1,59 +1,57 @@
import { sortBy, pipe } from "remeda"
export namespace Wildcard {
export function match(str: string, pattern: string) {
if (str) str = str.replaceAll("\\", "/")
if (pattern) pattern = pattern.replaceAll("\\", "/")
let escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape special regex chars
.replace(/\*/g, ".*") // * becomes .*
.replace(/\?/g, ".") // ? becomes .
export function match(str: string, pattern: string) {
if (str) str = str.replaceAll("\\", "/")
if (pattern) pattern = pattern.replaceAll("\\", "/")
let escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape special regex chars
.replace(/\*/g, ".*") // * becomes .*
.replace(/\?/g, ".") // ? becomes .
// If pattern ends with " *" (space + wildcard), make the trailing part optional
// This allows "ls *" to match both "ls" and "ls -la"
if (escaped.endsWith(" .*")) {
escaped = escaped.slice(0, -3) + "( .*)?"
}
const flags = process.platform === "win32" ? "si" : "s"
return new RegExp("^" + escaped + "$", flags).test(str)
// If pattern ends with " *" (space + wildcard), make the trailing part optional
// This allows "ls *" to match both "ls" and "ls -la"
if (escaped.endsWith(" .*")) {
escaped = escaped.slice(0, -3) + "( .*)?"
}
export function all(input: string, patterns: Record<string, any>) {
const sorted = pipe(patterns, Object.entries, sortBy([([key]) => key.length, "asc"], [([key]) => key, "asc"]))
let result = undefined
for (const [pattern, value] of sorted) {
if (match(input, pattern)) {
result = value
continue
}
}
return result
}
export function allStructured(input: { head: string; tail: string[] }, patterns: Record<string, any>) {
const sorted = pipe(patterns, Object.entries, sortBy([([key]) => key.length, "asc"], [([key]) => key, "asc"]))
let result = undefined
for (const [pattern, value] of sorted) {
const parts = pattern.split(/\s+/)
if (!match(input.head, parts[0])) continue
if (parts.length === 1 || matchSequence(input.tail, parts.slice(1))) {
result = value
continue
}
}
return result
}
function matchSequence(items: string[], patterns: string[]): boolean {
if (patterns.length === 0) return true
const [pattern, ...rest] = patterns
if (pattern === "*") return matchSequence(items, rest)
for (let i = 0; i < items.length; i++) {
if (match(items[i], pattern) && matchSequence(items.slice(i + 1), rest)) {
return true
}
}
return false
}
const flags = process.platform === "win32" ? "si" : "s"
return new RegExp("^" + escaped + "$", flags).test(str)
}
export function all(input: string, patterns: Record<string, any>) {
const sorted = pipe(patterns, Object.entries, sortBy([([key]) => key.length, "asc"], [([key]) => key, "asc"]))
let result = undefined
for (const [pattern, value] of sorted) {
if (match(input, pattern)) {
result = value
continue
}
}
return result
}
export function allStructured(input: { head: string; tail: string[] }, patterns: Record<string, any>) {
const sorted = pipe(patterns, Object.entries, sortBy([([key]) => key.length, "asc"], [([key]) => key, "asc"]))
let result = undefined
for (const [pattern, value] of sorted) {
const parts = pattern.split(/\s+/)
if (!match(input.head, parts[0])) continue
if (parts.length === 1 || matchSequence(input.tail, parts.slice(1))) {
result = value
continue
}
}
return result
}
function matchSequence(items: string[], patterns: string[]): boolean {
if (patterns.length === 0) return true
const [pattern, ...rest] = patterns
if (pattern === "*") return matchSequence(items, rest)
for (let i = 0; i < items.length; i++) {
if (match(items[i], pattern) && matchSequence(items.slice(i + 1), rest)) {
return true
}
}
return false
}